1.增加数据集等

2.yolo识别,采用全新的逻辑,增加数据集,数据,等区分
3.录像回放推流
4.增加识别逻辑
master
LAPTOP-S9HJSOEB\昊天 4 months ago
parent 3c439b31e0
commit cbbbfc123b

@ -0,0 +1,257 @@
# ONNX 直接内存不足问题解决指南
## 问题现象
```
java.lang.OutOfMemoryError: Unable to allocate 12582912 bytes
at java.nio.DirectByteBuffer.allocateDirect(ByteBuffer.java:343)
at ai.onnxruntime.OrtUtil.prepareBuffer(OrtUtil.java:507)
at ai.onnxruntime.OnnxTensor.createTensor(OnnxTensor.java:690)
```
## 问题分析
### 什么是直接内存(Direct Memory)?
直接内存是 Java 在堆外分配的内存,用于:
- **NIO 操作**:与操作系统进行高效数据交换
- **图像处理**:OpenCV、ONNX 等需要直接访问内存
- **GPU 交互**:与底层驱动通信
### 为什么会不足?
1. **Java 默认限制**
- 默认:接近系统内存上限
- 但实际可用受限于:
* 已分配的堆内存(2GB)
* 其他库占用的内存(OpenCV、相机驱动)
* 操作系统保留内存
2. **ONNX 的内存需求**
- 每次推理需要创建张量
- 高分辨率图像需要更多内存
- 并发请求会累积内存占用
3. **内存泄漏风险**
- DirectByteBuffer 需要手动释放
- ONNX 对象未正确关闭
- 请求过多,GC 无法及时回收
## 解决方案
### 方案 1:增加直接内存限制(推荐)
```bash
-XX:MaxDirectMemorySize=1g
```
**说明:**
- 将直接内存限制设置为 1GB
- 足够处理多个并发 ONNX 请求
- 在 7GB 系统上合理分配
**已更新文件:**
- `LxCameraApi-start.bat`
- `optimized-jvm-start-template.bat`
- `yudao-server/install-service.bat`
- `yudao-server/start.bat`
### 方案 2:优化 ONNX 代码
**在代码中确保资源释放:**
```java
// 错误示例
OnnxTensor tensor = OnnxTensor.createTensor(ortEnvironment, buffer, shape);
// 忘记释放!
// 正确示例
try (OnnxTensor tensor = OnnxTensor.createTensor(ortEnvironment, buffer, shape)) {
// 使用 tensor
} // 自动释放
// 或者手动释放
OnnxTensor tensor = OnnxTensor.createTensor(ortEnvironment, buffer, shape);
try {
// 使用 tensor
} finally {
tensor.close(); // 确保释放
}
```
**检查代码位置:**
- `com.example.lxcameraapi.service.IndustrialCamera.algorithm.ONNXServiceNew.detect:568`
- 确保所有 OnnxTensor 和 OrtSession 正确关闭
### 方案 3:减少并发请求数
```java
// 使用线程池限制并发
ExecutorService executor = Executors.newFixedThreadPool(2); // 限制为 2 个并发
// 或者使用信号量
Semaphore semaphore = new Semaphore(3); // 最多 3 个并发
```
### 方案 4:减小图像尺寸
```java
// 在推理前缩小图像
BufferedImage resizedImage = resize(originalImage, 640, 480);
// 使用 resizedImage 进行推理
```
## 完整的 JVM 参数配置
```bash
-server
# 堆内存
-Xms1g
-Xmx2g
# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 代码缓存
-XX:ReservedCodeCacheSize=512m
# 直接内存(关键!)
-XX:MaxDirectMemorySize=1g
# 堆地址(解决本地内存冲突)
-XX:HeapBaseMinAddress=0x100000000
# 垃圾回收器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# 错误处理
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=logs/heap_dump.hprof
-XX:ErrorFile=logs/hs_err_pid%%p.log
# 系统属性
-Dfile.encoding=UTF-8
-Duser.timezone=Asia/Shanghai
```
## 监控直接内存使用
### 1. 使用 JConsole
```bash
jconsole <pid>
```
查看:`内存` → `Buffer Pool` → `direct`
### 2. 使用 JMX
```java
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("java.nio:type=BufferPool,name=direct");
Long count = (Long) mbs.getAttribute(name, "Count");
Long totalCapacity = (Long) mbs.getAttribute(name, "TotalCapacity");
Long memoryUsed = (Long) mbs.getAttribute(name, "MemoryUsed");
System.out.println("Direct Buffers: " + count);
System.out.println("Total Capacity: " + (totalCapacity / 1024 / 1024) + " MB");
System.out.println("Memory Used: " + (memoryUsed / 1024 / 1024) + " MB");
```
### 3. 使用 JVM 参数监控
```bash
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
```
## 内存分配示例
### 单次 ONNX 推理的内存使用
| 组件 | 内存占用 | 说明 |
|------|---------|------|
| 输入图像(640x480x3) | 0.9 MB | RGB 图像数据 |
| ONNX 输入张量 | 1.8 MB | Float32 格式 |
| 中间激活值 | 5-20 MB | 模型大小决定 |
| 输出张量 | 0.1-1 MB | 取决于输出维度 |
| **总计** | **8-25 MB** | 单次推理 |
### 并发 3 个请求
- 直接内存使用:24-75 MB
- 加上缓冲和临时对象:约 100 MB
- **1GB 限制足够处理 10+ 个并发请求**
## 验证修复
启动应用后,检查日志:
```bash
# 查看堆地址(应该在 4GB 以上)
Heap address: 0x0000000100000000
# 如果没有 "MaxDirectMemorySize" 警告,说明参数生效
```
## 如果问题仍然存在
### 1. 进一步增加直接内存限制
```bash
-XX:MaxDirectMemorySize=2g
```
### 2. 禁用某些功能
如果不需要高并发,可以限制:
```bash
# 在 application.yml 中设置
server:
tomcat:
threads:
max: 10 # 减少最大线程数
```
### 3. 分析内存泄漏
使用以下工具:
```bash
# 使用 MAT 分析堆转储
mat heap_dump.hprof
# 查看 DirectByteBuffer 的实例数量
# 检查是否有未释放的 ONNX 对象
```
## 性能影响
### MaxDirectMemorySize 对性能的影响
| 大小 | 并发能力 | 性能影响 |
|------|---------|---------|
| 512MB | 5-10 个请求 | 可能受限 |
| 1GB | 10-20 个请求 | 平衡点 ✓ |
| 2GB | 20+ 个请求 | 轻微增加 GC 压力 |
**建议:从 1GB 开始,根据实际使用情况调整。**
## 相关文档
- `JVM_OPTIMIZATION_GUIDE.md` - 通用 JVM 优化指南
- `hs_err_pid*.log` - 崩溃日志分析
## 总结
**立即行动:**
1. 重新启动 `LxCameraApi-start.bat`(已包含 `-XX:MaxDirectMemorySize=1g`)
2. 测试 ONNX 推理功能
3. 监控直接内存使用情况
**如果还有问题:**
1. 检查代码是否有内存泄漏
2. 增加到 2GB
3. 减少并发请求数
4. 考虑增加物理内存到 16GB

@ -0,0 +1,108 @@
# JVM 崩溃问题解决方案
## 问题分析
JVM 崩溃发生在 JIT 编译线程中,编译 JSQLParser 时失败。
## 解决方案
### 1. 使用优化后的启动脚本
已创建 `start.bat`,包含优化的 JVM 参数:
- 堆内存:2G-4G(适应7G系统内存)
- 元空间:256M-512M
- JIT 代码缓存:256M
- 使用 G1 垃圾回收器
- 启用堆转储和错误日志
### 2. 如果问题仍然存在,尝试以下方案
#### 方案 A:禁用特定方法的 JIT 编译
在 start.bat 的 JAVA_OPTS 中添加:
```
-XX:CompileCommand=exclude,net/sf/jsqlparser/parser/CCJSqlParser,jj_3R_Alias_2314_5_165
```
#### 方案 B:使用 C2 编译器(性能更好,但内存占用更大)
```
-XX:+TieredCompilation -XX:TieredStopAtLevel=4
```
#### 方案 C:减少堆内存,增加系统内存
如果系统其他进程占用较多内存,调整:
```
-Xms1g -Xmx3g
```
### 3. 代码优化建议
#### 检查复杂 SQL 查询
搜索项目中可能存在的复杂查询:
```bash
grep -r "last(" --include="*.java" | head -20
```
#### 优化数据库查询
- 避免使用 `last()` 方法
- 使用分页查询代替 `limit 1`
- 添加适当的索引
#### 减少 MyBatis Plus 的复杂查询
```java
// 不推荐
.last("limit 1")
// 推荐
.last("LIMIT 1")
// 或者使用分页
.last("limit 1").orderByDesc("create_time")
```
### 4. 监控和诊断
#### 启用 GC 日志
在 start.bat 的 JAVA_OPTS 中添加:
```
-Xloggc:logs/gc.log ^
-XX:+PrintGCDetails ^
-XX:+PrintGCDateStamps
```
#### 监控内存使用
使用工具:
- JConsole
- VisualVM
- JProfiler
### 5. 紧急方案(如果必须立即运行)
#### 使用解释模式(不推荐,性能很差)
```bash
java -Xint -jar yudao-server.jar
```
#### 限制 JIT 编译线程数
```
-XX:CICompilerCount=2
```
## 长期优化建议
1. **升级硬件**
- 增加系统内存到至少 16GB
- 推荐配置:32GB 内存 + 4 核心 CPU
2. **优化应用架构**
- 分离数据库和应用服务器
- 使用微服务架构
- 减少单个应用的复杂度
3. **定期维护**
- 清理日志文件
- 定期重启应用
- 监控内存使用情况
## 联系方式
如果问题仍然存在,请提供:
1. 完整的 hs_err_pid.log 文件
2. 应用日志(logs 目录)
3. SQL 查询语句示例

@ -0,0 +1,163 @@
# JVM 本地内存不足问题解决指南
## 问题分析
### 错误信息
```
Native memory allocation (mmap) failed to map X bytes for G1 virtual space
```
### 根本原因
1. **地址空间冲突**
- Windows 7GB 内存系统,Java Heap 默认占用 2GB
- Compressed Oops 模式将 Heap 放在前 4GB 地址空间
- 本地内存(OpenCV、ONNX、相机库等)也需要地址空间
- 结果:地址空间不足,导致本地内存分配失败
2. **系统配置**
- 总内存:7GB
- Java Heap:2GB
- 剩余:5GB(用于操作系统、其他库、本地内存)
### 受影响的应用
- `yudao-server.jar`
- `LxCameraApi-1.0.1-SNAPSHOT.jar`(加载了 OpenCV、海康相机、ONNX)
## 解决方案
### 关键参数
```bash
# 1. 减小堆内存,为本地内存预留空间
-Xms1g -Xmx2g
# 2. 将 Java Heap 放在 4GB 地址空间以上(最重要!)
-XX:HeapBaseMinAddress=0x100000000
# 3. 增加 JIT 代码缓存
-XX:ReservedCodeCacheSize=512m
# 4. 使用 G1 垃圾回收器
-XX:+UseG1GC
```
### 使用方法
#### 方式 1:直接运行(推荐用于开发测试)
**yudao-server:**
```cmd
cd d:\git\lpNewgit\yudao-server
start.bat
```
**LxCameraApi:**
```cmd
cd d:\git\lpNewgit
LxCameraApi-start.bat
```
**通用模板(适用于其他应用):**
```cmd
cd d:\git\lpNewgit
optimized-jvm-start-template.bat
```
编辑 `optimized-jvm-start-template.bat` 修改:
- `JAR_PATH`:你的 JAR 包路径
- `APP_NAME`:应用名称
- `LOG_PATH`:日志路径
#### 方式 2:作为 Windows 服务运行(推荐用于生产环境)
**yudao-server:**
```cmd
cd d:\git\lpNewgit\yudao-server
install-service.bat
```
**卸载服务:**
```cmd
uninstall-service.bat
```
## 监控和验证
### 检查堆地址
启动后查看日志,确认堆地址已移到 4GB 以上:
```
Heap address: 0x0000000100000000 # 应该是 0x100000000 或更高
```
### 查看崩溃日志
如果仍然崩溃,查看:
```
D:\sy_software\application-app\logs\hs_err_pid*.log
```
### 性能监控
使用以下命令监控内存使用:
```cmd
# 查看 JVM 进程
tasklist | findstr java.exe
# 查看内存使用
wmic process where "name='java.exe'" get ProcessId,WorkingSetSize,PageFileUsage
```
## 其他优化建议
### 1. 如果还有内存问题
进一步减小堆内存:
```bash
-Xms512m -Xmx1.5g
```
### 2. 禁用 JIT 编译(极端情况)
```bash
-Xint
# 这会显著降低性能,但能解决 JIT 相关的内存问题
```
### 3. 增加 Windows 虚拟内存
1. 右键"此电脑" → 属性 → 高级系统设置
2. 性能 → 设置 → 高级 → 虚拟内存
3. 增加页面文件大小到 8GB 或更大
### 4. 升级硬件
最佳解决方案是增加物理内存到 16GB 或更高。
## 常见问题
### Q1: 为什么之前运行正常,现在才崩溃?
A: 应用运行时间越长,内存碎片越严重,最终导致无法分配大块连续内存。
### Q2: 减小堆内存会影响性能吗?
A: 轻微影响,但比崩溃要好。对于 7GB 系统,2GB 堆是合理的平衡点。
### Q3: `-XX:HeapBaseMinAddress` 参数安全吗?
A: 安全。这是官方推荐的解决方案,专门用于解决 Compressed Oops 和本地内存冲突问题。
### Q4: 为什么需要增加 CodeCache?
A: JIT 编译器需要本地内存来存储编译后的代码,增加缓存大小可以减少编译频率。
## 文件说明
- `optimized-jvm-start-template.bat` - 通用 JVM 优化启动模板
- `LxCameraApi-start.bat` - LxCameraApi 专用启动脚本
- `yudao-server/install-service.bat` - yudao-server 服务安装脚本
- `yudao-server/uninstall-service.bat` - 服务卸载脚本
- `yudao-server/start.bat` - yudao-server 直接启动脚本
## 联系支持
如果问题仍然存在,请提供以下信息:
1. `hs_err_pid*.log` 文件内容
2. 应用运行的 JAR 包名称
3. 加载了哪些第三方库(DLL)
4. 系统配置(CPU、内存、Windows 版本)

@ -0,0 +1,49 @@
@echo off
chcp 65001
title LxCameraApi - 优化版
:: ========================================
REM LxCameraApi 应用启动脚本
REM 解决本地内存不足问题
REM 已加载:OpenCV、海康相机、ONNX 等重型库
REM ========================================
set "JAVA_PATH=D:\sy_software\jdk-20\bin\java.exe"
set "JAR_PATH=D:\sy_software\application-app\LxCameraApi-1.0.1-SNAPSHOT.jar"
set "LOG_PATH=D:\sy_software\application-app\logs"
set "APP_NAME=LxCameraApi"
echo ========================================
echo 正在启动 LxCameraApi...
echo 已加载库:OpenCV、海康相机、ONNX
echo ========================================
:: 创建日志目录
if not exist "%LOG_PATH%" (
mkdir "%LOG_PATH%"
)
:: 优化的 JVM 参数(关键:HeapBaseMinAddress=0x100000000)
:: 增加直接内存限制(解决 ONNX 内存分配问题)
set "JVM_OPTS=-server ^
-Xms1g ^
-Xmx2g ^
-XX:MetaspaceSize=256m ^
-XX:MaxMetaspaceSize=512m ^
-XX:ReservedCodeCacheSize=512m ^
-XX:MaxDirectMemorySize=1g ^
-XX:HeapBaseMinAddress=0x100000000 ^
-XX:+UseG1GC ^
-XX:MaxGCPauseMillis=200 ^
-XX:+HeapDumpOnOutOfMemoryError ^
-XX:HeapDumpPath=%LOG_PATH%/%APP_NAME%_heap_dump.hprof ^
-XX:ErrorFile=%LOG_PATH%/hs_err_pid%%p.log ^
-Dfile.encoding=UTF-8 ^
-Duser.timezone=Asia/Shanghai"
echo JVM 参数: %JVM_OPTS%
echo.
"%JAVA_PATH%" %JVM_OPTS% -jar "%JAR_PATH%"
pause

@ -0,0 +1,110 @@
@echo off
REM ========================================
REM 优化的 JVM 启动参数模板
REM 用于解决 Windows 7GB 内存系统上的本地内存不足问题
REM ========================================
:: 设置命令行字符集为 UTF-8
chcp 65001
:: ========================================
REM 【必须修改】配置项
REM ========================================
:: Java 路径
set "JAVA_PATH=D:\sy_software\jdk-20\bin\java.exe"
:: JAR 包路径(根据你的应用修改)
set "JAR_PATH=D:\sy_software\application-app\yudao-server.jar"
:: 日志路径(确保目录存在)
set "LOG_PATH=D:\sy_software\application-app\logs"
:: 应用名称(用于日志文件名)
set "APP_NAME=yudao-server"
:: ========================================
REM 优化的 JVM 参数
REM ========================================
:: 内存配置
:: -Xms: 初始堆大小(1GB)
:: -Xmx: 最大堆大小(2GB,为其他进程和本地内存保留 5GB)
:: -XX:MetaspaceSize: 初始元空间大小
:: -XX:MaxMetaspaceSize: 最大元空间大小
:: -XX:ReservedCodeCacheSize: JIT 编译代码缓存(增加到 512MB)
:: 关键修复参数
:: -XX:HeapBaseMinAddress=0x100000000
:: 将 Java Heap 放在 4GB 地址空间以上
:: 避免与本地内存(OpenCV、ONNX、相机库等)地址冲突
:: -XX:MaxDirectMemorySize=1g
:: 设置直接内存最大限制为 1GB
:: 解决 ONNX、OpenCV 等需要分配大量直接内存的问题
set "JVM_OPTS=-server ^
-Xms1g ^
-Xmx2g ^
-XX:MetaspaceSize=256m ^
-XX:MaxMetaspaceSize=512m ^
-XX:ReservedCodeCacheSize=512m ^
-XX:MaxDirectMemorySize=1g ^
-XX:HeapBaseMinAddress=0x100000000"
:: 垃圾回收器配置
:: -XX:+UseG1GC: 使用 G1 垃圾回收器
:: -XX:MaxGCPauseMillis=200: 目标 GC 暂停时间 200ms
set "JVM_OPTS=%JVM_OPTS% ^
-XX:+UseG1GC ^
-XX:MaxGCPauseMillis=200"
:: 错误处理和日志
:: -XX:+HeapDumpOnOutOfMemoryError: OOM 时生成堆转储
:: -XX:HeapDumpPath: 堆转储文件路径
:: -XX:ErrorFile: JVM 崩溃日志路径
set "JVM_OPTS=%JVM_OPTS% ^
-XX:+HeapDumpOnOutOfMemoryError ^
-XX:HeapDumpPath=%LOG_PATH%/%APP_NAME%_heap_dump.hprof ^
-XX:ErrorFile=%LOG_PATH%/hs_err_pid%%p.log"
:: 系统属性
:: -Dfile.encoding: 文件编码
:: -Duser.timezone: 时区
set "JVM_OPTS=%JVM_OPTS% ^
-Dfile.encoding=UTF-8 ^
-Duser.timezone=Asia/Shanghai"
:: ========================================
REM 启动应用
REM ========================================
echo ========================================
echo 正在启动: %APP_NAME%
echo ========================================
echo Java 路径: %JAVA_PATH%
echo JAR 路径: %JAR_PATH%
echo 日志路径: %LOG_PATH%
echo.
echo JVM 参数:
echo -Xms1g -Xmx2g
echo -XX:HeapBaseMinAddress=0x100000000 (关键修复)
echo -XX:ReservedCodeCacheSize=512m
echo -XX:+UseG1GC
echo.
echo ========================================
:: 检查日志目录是否存在
if not exist "%LOG_PATH%" (
echo 创建日志目录: %LOG_PATH%
mkdir "%LOG_PATH%"
)
:: 启动应用
echo 启动中...
"%JAVA_PATH%" %JVM_OPTS% -jar "%JAR_PATH%"
echo.
echo ========================================
echo 应用已退出
echo ========================================
pause

@ -42,7 +42,7 @@
<maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
<flatten-maven-plugin.version>1.5.0</flatten-maven-plugin.version>
<!-- 看看咋放到 bom 里 -->
<lombok.version>1.18.30</lombok.version>
<lombok.version>1.18.34</lombok.version>
<spring.boot.version>3.2.2</spring.boot.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

Binary file not shown.

@ -47,7 +47,7 @@
<!-- 工具类相关 -->
<captcha-plus.version>2.0.3</captcha-plus.version>
<jsoup.version>1.17.2</jsoup.version>
<lombok.version>1.18.30</lombok.version>
<lombok.version>1.18.34</lombok.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<hutool-5.version>5.8.25</hutool-5.version>
<hutool-6.version>6.0.0-M10</hutool-6.version>

@ -220,6 +220,7 @@ public class LocalDateTimeUtils {
// 1.1 找到枚举
DateIntervalEnum intervalEnum = DateIntervalEnum.valueOf(interval);
Assert.notNull(intervalEnum, "interval({}} 找不到对应的枚举", interval);
// 1.2 将时间对齐
startTime = LocalDateTimeUtil.beginOfDay(startTime);
endTime = LocalDateTimeUtil.endOfDay(endTime);

@ -39,14 +39,14 @@ public abstract class BaseDO implements Serializable, TransPojo {
* 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。
*/
@TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR)
private String creator;
private Long creator;
/**
* 更新者,目前使用 SysUser 的 id 编号
*
* 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。
*/
@TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR)
private String updater;
private Long updater;
/**
* 是否删除
*/

@ -35,11 +35,11 @@ public class DefaultDBFieldHandler implements MetaObjectHandler {
Long userId = WebFrameworkUtils.getLoginUserId();
// 当前登录用户不为空,创建人为空,则当前登录用户为创建人
if (Objects.nonNull(userId) && Objects.isNull(baseDO.getCreator())) {
baseDO.setCreator(userId.toString());
baseDO.setCreator(userId);
}
// 当前登录用户不为空,更新人为空,则当前登录用户为更新人
if (Objects.nonNull(userId) && Objects.isNull(baseDO.getUpdater())) {
baseDO.setUpdater(userId.toString());
baseDO.setUpdater(userId);
}
}
}

@ -128,7 +128,7 @@ public class YudaoWebAutoConfiguration implements WebMvcConfigurer {
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(5000)) // 连接超时 5秒
.setConnectTimeout(Duration.ofMillis(15000)) // 连接超时 5秒
.setReadTimeout(Duration.ofMillis(20000)) // 读取超时 20秒
.build();
}

@ -28,9 +28,7 @@ import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@ -63,6 +61,16 @@ public class DatasController {
return success(true);
}
@PutMapping("/updateStatus")
@Operation(summary = "更新数据集管理")
@PreAuthorize("@ss.hasPermission('annotation:datas:update')")
public CommonResult<Boolean> updateStatus(@Valid @RequestBody DatasSaveReqVO updateReqVO) {
DatasDO datas = BeanUtils.toBean(updateReqVO, DatasDO.class);
datasService.updateById(datas);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除数据集管理")
@Parameter(name = "id", description = "编号", required = true)
@ -129,6 +137,32 @@ public class DatasController {
return success(true);
}
@PostMapping("/handleRefresh")
@Operation(summary = "刷新数据集:扫描新图片并按比例分配")
@Parameter(name = "id", description = "项目编号", required = true)
@PreAuthorize("@ss.hasPermission('annotation:datas:update')")
public CommonResult<Boolean> handleRefresh(@RequestParam("id") Integer id) {
datasService.handleRefresh(id);
return success(true);
}
@PostMapping("/start-train")
@Operation(summary = "启动YOLO训练")
@Parameter(name = "id", description = "项目编号", required = true)
@PreAuthorize("@ss.hasPermission('annotation:datas:update')")
public CommonResult<Boolean> startTrain(@RequestParam("id") Integer id) {
datasService.startTrain(id);
return success(true);
}
@GetMapping("/train-log")
@Operation(summary = "查看训练日志")
@Parameter(name = "id", description = "项目编号", required = true)
@PreAuthorize("@ss.hasPermission('annotation:datas:query')")
public CommonResult<String> getTrainLog(@RequestParam("id") Integer id) {
return success(datasService.getTrainLog(id));
}
@GetMapping("/page")
@Operation(summary = "获得数据集管理分页")
@PreAuthorize("@ss.hasPermission('annotation:datas:query')")

@ -20,6 +20,7 @@ import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@ -63,8 +64,18 @@ public class DatasetController {
@PostMapping("/add-images")
@Operation(summary = "向数据集添加图片")
@PreAuthorize("@ss.hasPermission('annotation:dataset:update')")
public CommonResult<List<String>> addImages(@RequestBody DatasetSaveReqVO updateReqVO) {
List<String> imageUrls = datasetService.addImages(updateReqVO.getId(), updateReqVO.getFiles());
public CommonResult<List<String>> addImages(@RequestParam(value = "id") Integer id,
@RequestParam(value = "files") MultipartFile[] files) {
List<String> imageUrls = datasetService.addImages(id, files);
return success(imageUrls);
}
@PostMapping("/{name}/upload")
@Operation(summary = "按名称上传文件到数据集")
@PreAuthorize("@ss.hasPermission('annotation:dataset:update')")
public CommonResult<List<String>> uploadFiles(@PathVariable("name") String name,
@RequestParam(value = "files") MultipartFile[] files) {
List<String> imageUrls = datasetService.uploadFiles(name, files);
return success(imageUrls);
}

@ -2,9 +2,7 @@ package cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.*;
import java.util.*;
@Schema(description = "管理后台 - 识别数据集新增/修改 Request VO")
@Data
@ -19,7 +17,4 @@ public class DatasetSaveReqVO {
@Schema(description = "路径,可以输入或者根据导入zip自动生成")
private String path;
@Schema(description = "上传的文件(zip压缩包或多张图片)")
private MultipartFile[] files;
}

@ -134,11 +134,4 @@ public class TrainController {
BeanUtils.toBean(list, TrainRespVO.class));
}
@GetMapping("/system-info")
@Operation(summary = "获取系统信息(CPU、GPU、Python环境)")
@PreAuthorize("@ss.hasPermission('annotation:train:query')")
public CommonResult<SystemInfoRespVO> getSystemInfo() {
return success(trainService.getSystemInfo());
}
}

@ -1,55 +0,0 @@
package cn.iocoder.yudao.module.annotation.controller.admin.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.ExportResult;
import cn.iocoder.yudao.module.annotation.dal.yolo.PredictionResult;
import cn.iocoder.yudao.module.annotation.dal.yolo.ValidationResult;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig;
import cn.iocoder.yudao.module.annotation.service.yolo.AsyncYoloService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.CompletableFuture;
// YoloController.java
@RestController
@RequestMapping("/api/yolo")
public class YoloController {
private final AsyncYoloService yoloService;
public YoloController(AsyncYoloService yoloService) {
this.yoloService = yoloService;
}
@PostMapping("/train")
public ResponseEntity<String> trainAsync(@RequestBody YoloConfig config) {
yoloService.trainAsync(config)
.thenAccept(result -> {
if (result.isSuccess()) {
System.out.println("Training completed successfully");
} else {
System.err.println("Training failed: " + result.getMessage());
}
});
return ResponseEntity.ok("Training started asynchronously");
}
@PostMapping("/validate")
public CompletableFuture<ValidationResult> validateAsync(@RequestBody YoloConfig config) {
return yoloService.validateAsync(config);
}
@PostMapping("/predict")
public CompletableFuture<PredictionResult> predictAsync(
@RequestBody YoloConfig config,
@RequestParam String imagePath) {
return yoloService.predictAsync(config, imagePath);
}
@PostMapping("/export")
public CompletableFuture<ExportResult> exportAsync(
@RequestBody YoloConfig config,
@RequestParam String format) {
return yoloService.exportAsync(config, format);
}
}

@ -1,190 +0,0 @@
package cn.iocoder.yudao.module.annotation.controller.admin.yolo;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.annotation.dal.dataobject.train.TrainDO;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig;
import cn.iocoder.yudao.module.annotation.service.python.PythonVirtualEnvService;
import cn.iocoder.yudao.module.annotation.service.python.vo.PythonVirtualEnvInfo;
import cn.iocoder.yudao.module.annotation.service.train.TrainService;
import cn.iocoder.yudao.module.annotation.service.yolo.YoloOperationService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - YOLO操作")
@RestController
@RequestMapping("/annotation/yolo")
@Validated
@Slf4j
public class YoloOperationController {
@Resource
private PythonVirtualEnvService pythonVirtualEnvService;
@Resource
private YoloOperationService yoloOperationService;
@Resource
private DictDataService dictDataService;
@PostMapping("/detect-env")
@Operation(summary = "检测Python和虚拟环境")
@PermitAll
public CommonResult<PythonVirtualEnvInfo> detectPythonAndVirtualEnv() {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
PythonVirtualEnvInfo info = pythonVirtualEnvService.detectPythonAndVirtualEnv(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue());
// 如果虚拟环境不存在,尝试创建虚拟环境
if (!info.getVirtualEnvExists() && info.getPythonExists()) {
log.info("虚拟环境不存在,尝试创建虚拟环境: {}", dictDataMap.get("python_venv").getValue());
info = pythonVirtualEnvService.createVirtualEnvironment(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue());
}
// 如果YOLO不存在,尝试安装YOLO
if (info.getVirtualEnvExists() && !info.getYoloInstalled()) {
log.info("YOLO未安装,尝试安装YOLO");
info = pythonVirtualEnvService.installYolo(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue());
}
log.info("最终环境信息: {}", info);
return success(info);
}
@PostMapping("/activate-env")
@Operation(summary = "进入虚拟环境")
@PermitAll
public CommonResult<PythonVirtualEnvInfo> activateVirtualEnv() {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
PythonVirtualEnvInfo info = pythonVirtualEnvService.activateVirtualEnv(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue());
return success(info);
}
@Resource
private TrainService trainService;
@PostMapping("/train")
@Operation(summary = "YOLO训练(异步)")
@PermitAll
public CommonResult<CompletableFuture<YoloConfig>> trainAsync(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
config.setTrainId(id);
CompletableFuture<YoloConfig> result = yoloOperationService.trainAsync(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@PostMapping("/validate")
@Operation(summary = "YOLO验证")
@PermitAll
public CommonResult<YoloConfig> validate(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
YoloConfig result = yoloOperationService.validate(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@PostMapping("/predict")
@Operation(summary = "YOLO预测")
@PermitAll
public CommonResult<YoloConfig> predict(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
YoloConfig result = yoloOperationService.predict(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@PostMapping("/classify")
@Operation(summary = "YOLO分类")
@PermitAll
public CommonResult<YoloConfig> classify(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
YoloConfig result = yoloOperationService.classify(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@PostMapping("/export")
@Operation(summary = "导出YOLO模型")
@PermitAll
public CommonResult<YoloConfig> export(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
YoloConfig result = yoloOperationService.export(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@PostMapping("/track")
@Operation(summary = "YOLO追踪")
@PermitAll
public CommonResult<YoloConfig> track(@RequestParam("trainId") Integer id) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
TrainDO trainDO = trainService.getTrain(id);
// trainDO.setPath(dictDataMap.get("training_address").getValue());
YoloConfig config = trainDO.getYolofig(dictDataMap);
config.setTrainId(id);
YoloConfig result = yoloOperationService.track(
dictDataMap.get("python_path").getValue(), dictDataMap.get("python_venv").getValue(), config);
return success(result);
}
@GetMapping("/task-status")
@Operation(summary = "获取异步任务状态")
@PermitAll
public CommonResult<String> getTaskStatus(
@RequestParam @Parameter(description = "任务ID", required = true) String taskId) {
// 这里可以添加任务状态查询逻辑
// 实际项目中可能需要使用缓存或数据库来存储任务状态
return success("Task status query not implemented yet");
}
}

@ -77,7 +77,7 @@ public class DatasDO extends BaseDO {
/**
* 批次
*/
private Double batch = -1.0;
private String batch = "0.8";
/**
* 图片大小
*/

@ -1,11 +1,10 @@
package cn.iocoder.yudao.module.annotation.dal.dataobject.dataset;
import lombok.*;
import java.util.*;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.*;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 识别数据集 DO
@ -35,5 +34,6 @@ public class DatasetDO extends BaseDO {
* 路径,可以输入或者根据导入zip自动生成
*/
private String path;
private String generationPath;
}

@ -25,4 +25,9 @@ public interface DatasetMapper extends BaseMapperX<DatasetDO> {
.orderByDesc(DatasetDO::getId));
}
default DatasetDO selectByName(String name) {
return selectOne(new LambdaQueryWrapperX<DatasetDO>()
.eq(DatasetDO::getName, name));
}
}

@ -1,8 +1,8 @@
// AnnotationData.java
package cn.iocoder.yudao.module.annotation.service.MarkInfo;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@ -15,7 +15,7 @@ import java.util.List;
@AllArgsConstructor
public class AnnotationData {
private String id;
private List<String> bodies;
private List<Object> bodies;
private Target target;
@Data
@ -56,19 +56,29 @@ public class AnnotationData {
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Creator {
private Boolean isGuest;
private String id;
private Object id;
}
}
private static final ObjectMapper MAPPER = new ObjectMapper();
public String toString() {
return JSONUtil.toJsonStr(this);
try {
return MAPPER.writeValueAsString(this);
} catch (Exception e) {
return "{}";
}
}
static public AnnotationData fromString(String json) {
if (json == null || json.isEmpty()) {
return null;
}
return JSONUtil.toBean(json, AnnotationData.class);
try {
return MAPPER.readValue(json, AnnotationData.class);
} catch (Exception e) {
return null;
}
}
}

@ -55,5 +55,28 @@ public interface DatasService extends IService<DatasDO> {
void refreshDatas(DatasDO datasDO);
/**
* 刷新项目数据集:扫描数据集目录下的新图片(不在mark表中的),
* 按训练/验证/测试比例分配并写入mark表
*
* @param id 项目ID
*/
void handleRefresh(Integer id);
/**
* 启动YOLO训练(异步)
*
* @param id 项目ID
*/
void startTrain(Integer id);
/**
* 获取训练日志
*
* @param id 项目ID
* @return 训练日志内容
*/
String getTrainLog(Integer id);
void enhance(DatasEnhance datasEnhance);
}

@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkInfoDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.types.TypesDO;
import cn.iocoder.yudao.module.annotation.dal.mysql.datas.DatasMapper;
import cn.iocoder.yudao.module.annotation.dal.mysql.mark.MarkInfoMapper;
import cn.iocoder.yudao.module.annotation.service.ImageService;
@ -34,6 +35,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
//import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*;
/**
@ -462,7 +464,7 @@ public class DatasServiceImpl extends ServiceImpl<DatasMapper, DatasDO> impleme
validateDatasExists(updateReqVO.getId());
// 删除旧的标记记录
markService.remove(new QueryWrapper<MarkDO>().eq("data_id", updateReqVO.getId()));
// markService.remove(new QueryWrapper<MarkDO>().eq("data_id", updateReqVO.getId()));
// 更新数据
DatasDO updateObj = BeanUtils.toBean(updateReqVO, DatasDO.class);
@ -552,6 +554,201 @@ public class DatasServiceImpl extends ServiceImpl<DatasMapper, DatasDO> impleme
log.warn("refreshDatas方法已废弃,请使用updateDatas方法更新数据集");
}
@Override
public void handleRefresh(Integer id) {
DatasDO datasDO = datasMapper.selectById(id);
if (datasDO == null) {
log.error("项目不存在, id={}", id);
return;
}
if (datasDO.getDatasets() == null || datasDO.getDatasets().isEmpty()) {
log.warn("项目没有关联数据集, id={}", id);
return;
}
// 1. 解析关联的数据集
String[] datasetIds = datasDO.getDatasets().split(",");
List<DatasetDO> datasetList = new ArrayList<>();
for (String datasetIdStr : datasetIds) {
try {
Integer datasetId = Integer.parseInt(datasetIdStr.trim());
DatasetDO dataset = datasetService.getDataset(datasetId);
if (dataset != null) {
datasetList.add(dataset);
}
} catch (NumberFormatException e) {
log.warn("无效的数据集ID: {}", datasetIdStr);
}
}
// 2. 收集所有数据集目录下的图片文件
List<String> allDiskImages = new ArrayList<>();
for (DatasetDO dataset : datasetList) {
if (dataset.getPath() != null && !dataset.getPath().isEmpty()) {
File datasetDir = new File(dataset.getPath());
if (datasetDir.exists()) {
allDiskImages.addAll(getAllImageFiles(datasetDir));
}
}
}
if (allDiskImages.isEmpty()) {
log.info("数据集目录下没有图片, id={}", id);
return;
}
// 3. 查出该项目已有的所有 mark 记录的 imagePath
List<MarkDO> existingMarks = markService.list(new QueryWrapper<MarkDO>().eq("data_id", id));
java.util.Set<String> existingImagePaths = new java.util.HashSet<>();
for (MarkDO mark : existingMarks) {
if (mark.getImagePath() != null) {
existingImagePaths.add(mark.getImagePath().replace("\\", "/"));
}
}
// 4. 过滤出新图片(不在 mark 表中的)
List<String> newImages = new ArrayList<>();
for (String imgPath : allDiskImages) {
if (!existingImagePaths.contains(imgPath.replace("\\", "/"))) {
newImages.add(imgPath);
}
}
if (newImages.isEmpty()) {
log.info("没有新图片需要刷新, id={}", id);
return;
}
log.info("发现 {} 张新图片需要分配, id={}", newImages.size(), id);
// 5. 获取字典配置
Map<String, DictDataDO> baseConf = dictDataService.getDictDataList("base_conf");
DictDataDO basePathDO = baseConf.get("base_path");
DictDataDO dataApiPathDO = baseConf.get("data_api_path");
if (basePathDO == null) {
log.error("base_conf.base_path 配置不存在");
return;
}
String basePath = basePathDO.getValue();
String dataApiPath = dataApiPathDO != null ? dataApiPathDO.getValue() : "";
// 6. YOLO 项目目录
String yoloProjectDir = basePath + File.separator + "yolo" + File.separator + id;
File yoloDir = new File(yoloProjectDir);
if (!yoloDir.exists()) {
yoloDir.mkdirs();
}
// 7. 按比例分配新图片
Collections.shuffle(newImages);
int totalNew = newImages.size();
int trainCount = (int) (totalNew * datasDO.getTrainRatio() / 100.0);
int valCount = (int) (totalNew * datasDO.getValRatio() / 100.0);
String projectType = datasDO.getType();
if ("2".equals(projectType)) {
// 分类任务
refreshClassificationImages(datasDO, yoloDir, basePath, dataApiPath, newImages, trainCount, valCount, totalNew);
} else if ("1".equals(projectType)) {
// 检测任务
refreshDetectionImages(datasDO, yoloDir, basePath, dataApiPath, newImages, trainCount, valCount, totalNew);
} else {
log.error("未知的任务类型: {}", projectType);
}
// 8. 更新图片总数
long totalCount = existingMarks.size() + newImages.size();
datasDO.setCount(totalCount);
datasMapper.updateById(datasDO);
log.info("刷新完成, id={}, 新增 {} 张图片, 总计 {} 张", id, newImages.size(), totalCount);
}
/**
* 刷新分类任务的新图片
*/
private void refreshClassificationImages(DatasDO datasDO, File yoloDir, String basePath,
String dataApiPath, List<String> newImages,
int trainCount, int valCount, int totalNew) {
File trainDir = new File(yoloDir, "train/default");
File valDir = new File(yoloDir, "val/default");
if (!trainDir.exists()) trainDir.mkdirs();
if (!valDir.exists()) valDir.mkdirs();
List<Map<Integer, String>> imagePaths = new ArrayList<>();
// 训练集
for (int i = 0; i < trainCount; i++) {
processClassificationImage(newImages.get(i), trainDir, basePath, dataApiPath,
datasDO.getId(), "train", "default", imagePaths);
}
// 验证集
for (int i = trainCount; i < trainCount + valCount; i++) {
processClassificationImage(newImages.get(i), valDir, basePath, dataApiPath,
datasDO.getId(), "val", "default", imagePaths);
}
// 测试集(合并到验证集)
for (int i = trainCount + valCount; i < totalNew; i++) {
processClassificationImage(newImages.get(i), valDir, basePath, dataApiPath,
datasDO.getId(), "val", "default", imagePaths);
}
// 写入 mark 表
saveMarks(datasDO.getId(), imagePaths);
}
/**
* 刷新检测任务的新图片
*/
private void refreshDetectionImages(DatasDO datasDO, File yoloDir, String basePath,
String dataApiPath, List<String> newImages,
int trainCount, int valCount, int totalNew) {
File imagesTrainDir = new File(yoloDir, "images/train");
File imagesValDir = new File(yoloDir, "images/val");
File labelsTrainDir = new File(yoloDir, "labels/train");
File labelsValDir = new File(yoloDir, "labels/val");
if (!imagesTrainDir.exists()) imagesTrainDir.mkdirs();
if (!imagesValDir.exists()) imagesValDir.mkdirs();
if (!labelsTrainDir.exists()) labelsTrainDir.mkdirs();
if (!labelsValDir.exists()) labelsValDir.mkdirs();
List<Map<Integer, String>> imagePaths = new ArrayList<>();
// 训练集
for (int i = 0; i < trainCount; i++) {
processDetectionImage(newImages.get(i), imagesTrainDir, labelsTrainDir, basePath, dataApiPath,
datasDO.getId(), "train", imagePaths);
}
// 验证集
for (int i = trainCount; i < trainCount + valCount; i++) {
processDetectionImage(newImages.get(i), imagesValDir, labelsValDir, basePath, dataApiPath,
datasDO.getId(), "val", imagePaths);
}
// 测试集(合并到验证集)
for (int i = trainCount + valCount; i < totalNew; i++) {
processDetectionImage(newImages.get(i), imagesValDir, labelsValDir, basePath, dataApiPath,
datasDO.getId(), "val", imagePaths);
}
// 写入 mark 表
saveMarks(datasDO.getId(), imagePaths);
}
/**
* 批量保存 mark 记录
*/
private void saveMarks(Integer dataId, List<Map<Integer, String>> imagePaths) {
for (Map<Integer, String> imageInfo : imagePaths) {
markService.save(new MarkDO()
.setDataId(dataId)
.setPath(imageInfo.get(0))
.setImagePath(imageInfo.get(1))
.setLabelPath(imageInfo.get(2))
.setStatus(0));
}
}
@Override
public void enhance(DatasEnhance datasEnhance) {
DictDataDO basePathDO = dictDataService.parseDictData("visual_annotation_conf", "base_path");
@ -608,4 +805,222 @@ public class DatasServiceImpl extends ServiceImpl<DatasMapper, DatasDO> impleme
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
/** 训练进行中的项目集合,防止重复启动 */
private static final ConcurrentHashMap<Integer, Boolean> TRAINING_MAP = new ConcurrentHashMap<>();
@Override
public void startTrain(Integer id) {
DatasDO datasDO = datasMapper.selectById(id);
if (datasDO == null) {
log.error("项目不存在, id={}", id);
return;
}
// 防止重复训练
if (TRAINING_MAP.containsKey(id)) {
log.warn("项目正在训练中, id={}", id);
return;
}
// 获取字典配置
Map<String, DictDataDO> baseConf = dictDataService.getDictDataList("base_conf");
DictDataDO basePathDO = baseConf.get("base_path");
DictDataDO yoloPathDO = baseConf.get("yolo_path");
if (basePathDO == null) {
log.error("base_conf.base_path 配置不存在");
return;
}
if (yoloPathDO == null) {
log.error("base_conf.yoloPath 配置不存在");
return;
}
String basePath = basePathDO.getValue();
String yoloEnvPath = yoloPathDO.getValue();
String yoloProjectDir = basePath + File.separator + "yolo" + File.separator + id;
// 检查 YOLO 目录是否存在
File yoloDir = new File(yoloProjectDir);
if (!yoloDir.exists()) {
log.error("YOLO项目目录不存在: {}", yoloProjectDir);
return;
}
// 1. 生成 data.yaml
generateDataYaml(datasDO, yoloDir);
// 2. 更新状态为训练中
datasDO.setStatus("2");
datasDO.setProgress(0.0);
datasMapper.updateById(datasDO);
// 3. 异步执行训练
TRAINING_MAP.put(id, true);
CompletableFuture.runAsync(() -> {
Process process = null;
try {
// 构建 yolo CLI 可执行路径
String yoloExe = yoloEnvPath + File.separator + "Scripts" + File.separator + "yolo.exe";
// 构建训练命令
List<String> command = new ArrayList<>();
command.add(yoloExe);
// YOLO CLI 参数
command.add("train");
command.add("data=" + new File(yoloDir, "data.yaml").getAbsolutePath());
command.add("epochs=" + datasDO.getEpochs());
command.add("batch=" + datasDO.getBatch());
command.add("imgsz=" + datasDO.getImageSize());
command.add("project=" + yoloProjectDir);
command.add("model=D:\\git\\lpNewgit\\yolo26n.pt");
// 打印完整 CLI 到控制台
String cliString = String.join(" ", command);
log.info("====== YOLO 训练启动 ======");
log.info("CLI: {}", cliString);
System.out.println("YOLO CLI: " + cliString);
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
pb.environment().put("PYTHONIOENCODING", "utf-8");
process = pb.start();
// 读取输出并写入日志文件,同时打印到控制台
File logFile = new File(yoloDir, "log.txt");
try (java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream(), "utf-8"));
java.io.BufferedWriter writer = new java.io.BufferedWriter(
new java.io.FileWriter(logFile, false))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
log.info("[YOLO-{}] {}", id, line);
// 过滤日志:当存在百分号时,只保留100%的行
if (line.contains("%")) {
if (line.contains("100%") || line.contains("100/")) {
writer.write(line);
writer.newLine();
}
// 其他百分比行跳过不写
} else {
writer.write(line);
writer.newLine();
}
writer.flush();
}
}
int exitCode = process.waitFor();
log.info("====== YOLO 训练结束, exitCode={} ======", exitCode);
System.out.println("YOLO 训练结束, exitCode=" + exitCode);
// 更新状态
if (exitCode == 0) {
datasDO.setStatus("3"); // 训练完成
datasDO.setProgress(100.0);
// 找到最佳模型路径
File trainDir = new File(yoloProjectDir, "train");
if (trainDir.exists()) {
// 查找最新的 train* 目录
File[] trainDirs = trainDir.getParentFile().listFiles(
(dir, name) -> name.startsWith("train"));
if (trainDirs != null && trainDirs.length > 0) {
File lastTrainDir = trainDirs[trainDirs.length - 1];
File bestModel = new File(lastTrainDir, "weights/best.pt");
if (bestModel.exists()) {
datasDO.setModelPath(bestModel.getAbsolutePath());
log.info("最佳模型路径: {}", bestModel.getAbsolutePath());
}
}
}
} else {
datasDO.setStatus("0"); // 训练失败,回到初始状态
datasDO.setProgress(0.0);
}
datasMapper.updateById(datasDO);
} catch (Exception e) {
log.error("YOLO 训练异常, id={}", id, e);
System.err.println("YOLO 训练异常: " + e.getMessage());
datasDO.setStatus("0");
datasDO.setProgress(0.0);
datasMapper.updateById(datasDO);
} finally {
TRAINING_MAP.remove(id);
if (process != null) {
process.destroyForcibly();
}
}
});
}
@Override
public String getTrainLog(Integer id) {
Map<String, DictDataDO> baseConf = dictDataService.getDictDataList("base_conf");
DictDataDO basePathDO = baseConf.get("base_path");
if (basePathDO == null) {
return "配置不存在";
}
String basePath = basePathDO.getValue();
File logFile = new File(basePath + File.separator + "yolo" + File.separator + id, "log.txt");
if (!logFile.exists()) {
return "暂无训练日志";
}
try {
return Files.readString(logFile.toPath());
} catch (IOException e) {
log.error("读取训练日志失败", e);
return "读取日志失败: " + e.getMessage();
}
}
/**
* 生成 data.yaml 文件
*/
private void generateDataYaml(DatasDO datasDO, File yoloDir) {
String projectType = datasDO.getType();
StringBuilder yaml = new StringBuilder();
// 获取类别列表
List<TypesDO> typesList = typesService.list(new QueryWrapper<TypesDO>()
.eq("data_id", datasDO.getId()));
String basePath = yoloDir.getAbsolutePath();
if ("1".equals(projectType)) {
// 检测任务
yaml.append("path: ").append(basePath).append("\n");
yaml.append("train: ").append(basePath).append("/images/train\n");
yaml.append("val: ").append(basePath).append("/images/val\n");
yaml.append("\nnc: ").append(typesList.size()).append("\n");
yaml.append("names:\n");
for (int i = 0; i < typesList.size(); i++) {
yaml.append(" ").append(i).append(": ").append(typesList.get(i).getName()).append("\n");
}
} else if ("2".equals(projectType)) {
// 分类任务
yaml.append("path: ").append(basePath).append("\n");
yaml.append("train: ").append(basePath).append("/train\n");
yaml.append("val: ").append(basePath).append("/val\n");
yaml.append("\nnc: ").append(typesList.size()).append("\n");
yaml.append("names:\n");
for (int i = 0; i < typesList.size(); i++) {
yaml.append(" ").append(i).append(": ").append(typesList.get(i).getName()).append("\n");
}
}
try {
File yamlFile = new File(yoloDir, "data.yaml");
Files.writeString(yamlFile.toPath(), yaml.toString());
log.info("生成 data.yaml: {}", yamlFile.getAbsolutePath());
} catch (IOException e) {
log.error("生成 data.yaml 失败", e);
}
}
}

@ -5,7 +5,6 @@ import jakarta.validation.*;
import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.*;
import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import org.springframework.web.multipart.MultipartFile;
/**
@ -62,4 +61,13 @@ public interface DatasetService {
*/
List<String> addImages(Integer id, MultipartFile[] files);
/**
* 按名称上传文件到数据集
*
* @param name 数据集名称
* @param files 文件数组
* @return 保存的图片URL列表
*/
List<String> uploadFiles(String name, MultipartFile[] files);
}

@ -1,32 +1,31 @@
package cn.iocoder.yudao.module.annotation.service.dataset;
import org.springframework.stereotype.Service;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.DatasetPageReqVO;
import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.DatasetSaveReqVO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO;
import cn.iocoder.yudao.module.annotation.dal.mysql.dataset.DatasetMapper;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import cn.iocoder.yudao.module.annotation.controller.admin.dataset.vo.*;
import cn.iocoder.yudao.module.annotation.dal.dataobject.dataset.DatasetDO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.annotation.dal.mysql.dataset.DatasetMapper;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.*;
import lombok.extern.slf4j.Slf4j;
import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.DATASET_NOT_EXISTS;
import static cn.iocoder.yudao.module.annotation.enums.ErrorCodeConstants.DATASET_PATH_NOT_SET;
/**
* 识别数据集 Service 实现类
@ -44,121 +43,98 @@ public class DatasetServiceImpl implements DatasetService {
@Resource
private DictDataService dictDataService;
/**
* 路径配置上下文 —— 一次请求内只查一次数据库
*/
@AllArgsConstructor
private static class PathConfig {
final String basePath; // 文件存储根路径,如 D:/data/
/** 数据集实体目录:basePath + dataset/{id}/ */
String datasetDir(Integer datasetId) {
return basePath + "/dataset/" + datasetId + "/";
}
/** 数据集内某个文件的实体路径:basePath + dataset/{id}/{fileName} */
String datasetFile(Integer datasetId, String fileName) {
return datasetDir(datasetId) + fileName;
}
}
/** 加载路径配置(每次请求调用一次) */
private PathConfig loadPathConfig() {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("base_conf");
String basePath = dictDataMap.get("base_path").getValue();
// String baseUrl = dictDataMap.get("data_api_path").getValue();
return new PathConfig(basePath);
}
/** 确保数据集目录存在,返回目录 File */
private File ensureDatasetDir(PathConfig config, Integer datasetId) {
File dir = new File(config.datasetDir(datasetId));
if (!dir.exists()) {
dir.mkdirs();
log.info("创建数据集目录: {}", dir.getAbsolutePath());
}
return dir;
}
// ==================== 业务方法 ====================
@Override
public Integer createDataset(DatasetSaveReqVO createReqVO) {
// 插入
DatasetDO dataset = BeanUtils.toBean(createReqVO, DatasetDO.class);
// 如果未指定路径,自动生成路径
// 先插入获取ID
datasetMapper.insert(dataset);
Integer id = dataset.getId();
// 如果未指定路径,自动生成路径并创建目录
if (dataset.getPath() == null || dataset.getPath().trim().isEmpty()) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
String basePath = dictDataMap.get("base_path").getValue();
String baseUrl = dictDataMap.get("base_url").getValue();
// 插入数据库获取ID
datasetMapper.insert(dataset);
Integer id = dataset.getId();
// 生成路径:base_path/dataset/id/
String datasetPath = basePath + "dataset/" + id + "/";
String datasetUrl = baseUrl + "dataset/" + id + "/";
// 创建目录
File dir = new File(datasetPath);
if (!dir.exists()) {
dir.mkdirs();
log.info("创建数据集目录: {}", datasetPath);
}
// 更新路径
dataset.setPath(datasetUrl);
PathConfig config = loadPathConfig();
ensureDatasetDir(config, id);
dataset.setPath(config.datasetDir(id));
datasetMapper.updateById(dataset);
log.info("创建数据集,ID={}, 自动生成路径={}", id, datasetUrl);
} else {
// 使用指定路径
datasetMapper.insert(dataset);
log.info("创建数据集,ID={}, 自动生成路径={}", id, dataset.getPath());
}
// 如果有上传文件,处理文件
if (createReqVO.getFiles() != null && createReqVO.getFiles().length > 0) {
processFiles(dataset.getId(), createReqVO.getFiles());
}
// 返回
return dataset.getId();
return id;
}
@Override
public void updateDataset(DatasetSaveReqVO updateReqVO) {
// 校验存在
validateDatasetExists(updateReqVO.getId());
// 更新
DatasetDO updateObj = BeanUtils.toBean(updateReqVO, DatasetDO.class);
// 如果更新了路径,且之前路径为空或使用默认路径,需要处理
DatasetDO existingDataset = datasetMapper.selectById(updateReqVO.getId());
if (existingDataset != null && updateObj.getPath() != null &&
!updateObj.getPath().equals(existingDataset.getPath())) {
// 如果指定了新的路径,直接使用
datasetMapper.updateById(updateObj);
} else if (existingDataset != null &&
(existingDataset.getPath() == null || existingDataset.getPath().trim().isEmpty())) {
// 如果之前路径为空,生成默认路径
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
String basePath = dictDataMap.get("base_path").getValue();
String baseUrl = dictDataMap.get("base_url").getValue();
String datasetPath = basePath + "dataset/" + updateReqVO.getId() + "/";
String datasetUrl = baseUrl + "dataset/" + updateReqVO.getId() + "/";
// 创建目录
File dir = new File(datasetPath);
if (!dir.exists()) {
dir.mkdirs();
log.info("创建数据集目录: {}", datasetPath);
}
updateObj.setPath(datasetUrl);
datasetMapper.updateById(updateObj);
} else {
datasetMapper.updateById(updateObj);
}
// 如果有上传文件,处理文件
if (updateReqVO.getFiles() != null && updateReqVO.getFiles().length > 0) {
processFiles(updateReqVO.getId(), updateReqVO.getFiles());
DatasetDO existing = datasetMapper.selectById(updateReqVO.getId());
// 如果之前路径为空,自动生成路径并创建目录
if (existing != null &&
(existing.getPath() == null || existing.getPath().trim().isEmpty())) {
PathConfig config = loadPathConfig();
ensureDatasetDir(config, updateReqVO.getId());
updateObj.setPath(config.datasetDir(updateReqVO.getId()));
}
datasetMapper.updateById(updateObj);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteDataset(Integer id) {
// 校验存在
validateDatasetExists(id);
// 获取数据集信息
DatasetDO dataset = datasetMapper.selectById(id);
// 删除数据库记录
datasetMapper.deleteById(id);
// 删除对应的文件夹
if (dataset != null && dataset.getPath() != null && !dataset.getPath().trim().isEmpty()) {
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
String basePath = dictDataMap.get("base_path").getValue();
String baseUrl = dictDataMap.get("base_url").getValue();
// 从URL转换为文件路径
String datasetPath = dataset.getPath().replace(baseUrl, basePath);
File datasetDir = new File(datasetPath);
File datasetDir = new File(dataset.getPath());
if (datasetDir.exists() && datasetDir.isDirectory()) {
boolean deleted = deleteDirectory(datasetDir);
if (deleted) {
log.info("删除数据集文件夹成功: {}", datasetPath);
log.info("删除数据集文件夹成功: {}", datasetDir.getAbsolutePath());
} else {
log.warn("删除数据集文件夹失败: {}", datasetPath);
log.warn("删除数据集文件夹失败: {}", datasetDir.getAbsolutePath());
}
}
}
@ -166,45 +142,42 @@ public class DatasetServiceImpl implements DatasetService {
@Override
public List<String> addImages(Integer id, MultipartFile[] files) {
// 校验数据集存在
validateDatasetExists(id);
// 获取数据集信息
DatasetDO dataset = datasetMapper.selectById(id);
if (dataset == null || dataset.getPath() == null || dataset.getPath().trim().isEmpty()) {
throw exception(DATASET_PATH_NOT_SET);
}
return processFiles(id, files);
PathConfig config = loadPathConfig();
return processFiles(config, id, files);
}
@Override
public List<String> uploadFiles(String name, MultipartFile[] files) {
DatasetDO dataset = datasetMapper.selectByName(name);
if (dataset == null) {
throw exception(DATASET_NOT_EXISTS);
}
PathConfig config = loadPathConfig();
ensureDatasetDir(config, dataset.getId());
return processFiles(config, dataset.getId(), files);
}
// ==================== 文件处理 ====================
/**
* 处理上传的文件(支持zip和多张图片)
*
* @param datasetId 数据集ID
* @param files 文件数组
* @return 保存的图片URL列表
*/
private List<String> processFiles(Integer datasetId, MultipartFile[] files) {
private List<String> processFiles(PathConfig config, Integer datasetId, MultipartFile[] files) {
if (files == null || files.length == 0) {
log.warn("没有上传任何文件");
return new ArrayList<>();
}
Map<String, DictDataDO> dictDataMap = dictDataService.getDictDataList("visual_annotation_conf");
String basePath = dictDataMap.get("base_path").getValue();
String baseUrl = dictDataMap.get("base_url").getValue();
// 目标文件夹:base_path/dataset/id/
File targetDir = new File(basePath + "dataset/" + datasetId + "/");
if (!targetDir.exists()) {
targetDir.mkdirs();
log.info("创建目标文件夹: {}", targetDir.getAbsolutePath());
}
List<String> savedImagePaths = new ArrayList<>();
File targetDir = ensureDatasetDir(config, datasetId);
List<String> savedUrls = new ArrayList<>();
for (MultipartFile file : files) {
try {
String originalFilename = file.getOriginalFilename();
@ -212,18 +185,17 @@ public class DatasetServiceImpl implements DatasetService {
log.warn("文件名为空,跳过");
continue;
}
// 检查是否是zip文件
if (originalFilename.toLowerCase().endsWith(".zip")) {
// 解压zip文件
List<String> extractedFiles = extractZipFile(file, targetDir, baseUrl, datasetId);
savedImagePaths.addAll(extractedFiles);
log.info("解压zip文件: {}, 提取 {} 个文件", originalFilename, extractedFiles.size());
// 解压zip,提取图片保存到目标目录
List<String> extracted = extractZipFile(file, targetDir, config, datasetId);
savedUrls.addAll(extracted);
log.info("解压zip文件: {}, 提取 {} 个图片", originalFilename, extracted.size());
} else if (isImageFile(originalFilename)) {
// 保存图片文件
String savedPath = saveImageFile(file, targetDir, baseUrl, datasetId);
if (savedPath != null && !savedPath.isEmpty()) {
savedImagePaths.add(savedPath);
// 直接保存图片到目标目录
String url = saveImageFile(file, targetDir, config, datasetId);
if (url != null) {
savedUrls.add(url);
}
} else {
log.warn("非zip或图片文件,跳过: {}", originalFilename);
@ -232,100 +204,70 @@ public class DatasetServiceImpl implements DatasetService {
log.error("处理文件失败: {}", file.getOriginalFilename(), e);
}
}
log.info("处理文件完成,数据集ID={}, 共保存 {} 个文件", datasetId, savedImagePaths.size());
return savedImagePaths;
log.info("处理文件完成,数据集ID={}, 共保存 {} 个文件", datasetId, savedUrls.size());
return savedUrls;
}
/**
* 解压zip文件
*
* @param zipFile zip文件
* @param targetDir 目标目录
* @param baseUrl 基础URL
* @param datasetId 数据集ID
* @return 解压后的图片URL列表
* 解压zip文件,提取图片保存到目标目录
*/
private List<String> extractZipFile(MultipartFile zipFile, File targetDir, String baseUrl, Integer datasetId) throws IOException {
List<String> extractedFiles = new ArrayList<>();
private List<String> extractZipFile(MultipartFile zipFile, File targetDir, PathConfig config, Integer datasetId) throws IOException {
List<String> extractedUrls = new ArrayList<>();
try (ZipInputStream zipIn = new ZipInputStream(zipFile.getInputStream())) {
ZipEntry entry;
byte[] buffer = new byte[1024];
byte[] buffer = new byte[4096];
while ((entry = zipIn.getNextEntry()) != null) {
String entryName = entry.getName();
// 跳过目录
if (entry.isDirectory()) {
continue;
}
// 检查是否是图片文件
if (!isImageFile(entryName)) {
log.debug("跳过非图片文件: {}", entryName);
if (!isImageFile(entry.getName())) {
log.debug("跳过非图片文件: {}", entry.getName());
continue;
}
// 提取文件名(去掉路径)
String fileName = getFileNameFromPath(entryName);
String fileName = getFileNameFromPath(entry.getName());
// 避免文件名冲突
fileName = generateUniqueFileName(targetDir, fileName);
File outputFile = new File(targetDir, fileName);
// 保存文件
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
int len;
while ((len = zipIn.read(buffer)) > 0) {
fos.write(buffer, 0, len);
}
}
String fileUrl = baseUrl + "dataset/" + datasetId + "/" + fileName;
extractedFiles.add(fileUrl);
log.info("解压文件: {} -> {}", entryName, fileUrl);
extractedUrls.add(config.datasetFile(datasetId, fileName));
log.info("解压文件: {} -> {}", entry.getName(), outputFile.getAbsolutePath());
}
zipIn.closeEntry();
}
return extractedFiles;
return extractedUrls;
}
/**
* 保存图片文件
*
* @param file 图片文件
* @param targetDir 目标目录
* @param baseUrl 基础URL
* @param datasetId 数据集ID
* @return 图片URL
* 保存单张图片到目标目录
*/
private String saveImageFile(MultipartFile file, File targetDir, String baseUrl, Integer datasetId) throws IOException {
private String saveImageFile(MultipartFile file, File targetDir, PathConfig config, Integer datasetId) throws IOException {
String originalFilename = file.getOriginalFilename();
String fileExtension = getFileExtension(originalFilename);
// 生成唯一文件名(避免重复)
String uniqueFileName = generateUniqueFileName(targetDir, originalFilename, fileExtension);
String uniqueFileName = generateUniqueFileName(targetDir, originalFilename);
File targetFile = new File(targetDir, uniqueFileName);
// 保存文件
file.transferTo(targetFile);
file.transferTo(targetFile.getAbsoluteFile());
log.info("保存图片: {} -> {}", originalFilename, targetFile.getAbsolutePath());
// 返回URL路径
return baseUrl + "dataset/" + datasetId + "/" + uniqueFileName;
return config.datasetFile(datasetId, uniqueFileName);
}
/**
* 递归删除目录
*
* @param directory 目录
* @return 是否删除成功
*/
// ==================== 工具方法 ====================
private boolean deleteDirectory(File directory) {
if (directory == null || !directory.exists()) {
return false;
}
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
@ -336,63 +278,36 @@ public class DatasetServiceImpl implements DatasetService {
}
}
}
return directory.delete();
}
/**
* 判断文件是否为图片
*/
private boolean isImageFile(String fileName) {
String lowerName = fileName.toLowerCase();
return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") ||
lowerName.endsWith(".png") || lowerName.endsWith(".bmp") ||
return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") ||
lowerName.endsWith(".png") || lowerName.endsWith(".bmp") ||
lowerName.endsWith(".gif");
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String fileName) {
int lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
return fileName.substring(lastDotIndex + 1).toLowerCase();
}
return "";
}
/**
* 从路径中提取文件名
*/
private String getFileNameFromPath(String path) {
int lastSlashIndex = path.lastIndexOf("/");
if (lastSlashIndex >= 0) {
return path.substring(lastSlashIndex + 1);
}
return path;
int slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return slash >= 0 ? path.substring(slash + 1) : path;
}
/**
* 生成唯一文件名
*/
private String generateUniqueFileName(File targetDir, String originalFileName, String extension) {
private String generateUniqueFileName(File targetDir, String originalFileName) {
String baseName = originalFileName;
String extension = "";
int dotIndex = originalFileName.lastIndexOf(".");
if (dotIndex > 0) {
baseName = originalFileName.substring(0, dotIndex);
extension = originalFileName.substring(dotIndex); // 包含 .
}
String fileName = originalFileName;
int counter = 1;
// 如果文件已存在,添加序号
File file = new File(targetDir, fileName);
while (file.exists()) {
fileName = baseName + "_" + counter + "." + extension;
file = new File(targetDir, fileName);
while (new File(targetDir, fileName).exists()) {
fileName = baseName + "_" + counter + extension;
counter++;
}
return fileName;
}
@ -412,4 +327,4 @@ public class DatasetServiceImpl implements DatasetService {
return datasetMapper.selectPage(pageReqVO);
}
}
}

@ -1,48 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.python;
import cn.iocoder.yudao.module.annotation.service.python.vo.PythonVirtualEnvInfo;
/**
* Python虚拟环境服务接口
*
* @author 管理员
*/
public interface PythonVirtualEnvService {
/**
* 进入指定的Python虚拟环境
*
* @param pythonPath Python可执行文件路径
* @param envName 虚拟环境名称
* @return 虚拟环境信息
*/
PythonVirtualEnvInfo activateVirtualEnv(String pythonPath, String envName);
/**
* 检测Python和虚拟环境
*
* @param pythonPath Python可执行文件路径
* @param envName 虚拟环境名称(可为null,表示检测Python本身)
* @return 检测结果
*/
PythonVirtualEnvInfo detectPythonAndVirtualEnv(String pythonPath, String envName);
/**
* 创建Python虚拟环境
*
* @param pythonPath Python可执行文件路径
* @param envName 虚拟环境名称
* @return 创建结果
*/
PythonVirtualEnvInfo createVirtualEnvironment(String pythonPath, String envName);
/**
* 在虚拟环境中安装YOLO
*
* @param pythonPath Python可执行文件路径
* @param envName 虚拟环境名称
* @return 安装结果
*/
PythonVirtualEnvInfo installYolo(String pythonPath, String envName);
}

@ -1,668 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.python;
import cn.iocoder.yudao.module.annotation.service.python.vo.PythonVirtualEnvInfo;
import cn.iocoder.yudao.module.annotation.service.yolo.YoloTrainConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
/**
* Python虚拟环境服务实现类
*
* @author 管理员
*/
@Slf4j
@Service
public class PythonVirtualEnvServiceImpl implements PythonVirtualEnvService {
/**
* 执行YOLO训练 最终版(2026/4/10)
* @param config 训练配置
* @return 训练结果径
*/
public static String executeTraining(YoloTrainConfig config) {
// ========== 配置参数 ==========
// YOLO训练参数
String modelPath = config.getModelPath();
String datasetPath = config.getDatasetPath();
int epochs = config.getEpochs();
int batchSize = config.getBatchSize();
int imageSize = config.getImageSize();
String device = config.getDevice();
String outputPath = config.getOutputPath();
String logFilePath = config.getLogFilePath();
int projectId = config.getProjectId();
String venvPath = config.getVenvPath();
String resultPath = null;
// 检测操作系统
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
// 虚拟环境激活脚本路径
String activateScript;
String shellCommand;
if (isWindows) {
activateScript = venvPath + "/Scripts/activate.bat";
shellCommand = "cmd";
} else {
activateScript = venvPath + "/bin/activate";
shellCommand = "bash";
}
String batchFilePath = outputPath + "/train" + projectId + (isWindows ? ".bat" : ".sh");
// 创建批处理文件内容
StringBuilder batchContent = new StringBuilder();
if (isWindows) {
// Windows 批处理文件
batchContent.append("@echo off\r\n");
batchContent.append("call \"").append(activateScript).append("\"\r\n");
batchContent.append("yolo train model=\"").append(modelPath).append("\" data=\"").append(datasetPath)
.append("\" epochs=").append(epochs).append(" batch=").append(batchSize)
.append(" imgsz=").append(imageSize).append(" device=").append(device)
.append(" project=\"").append(outputPath).append("\" name=train").append(projectId).append("\"\r\n");
} else {
// Linux Shell 脚本
batchContent.append("#!/bin/bash\n");
batchContent.append("source \"").append(activateScript).append("\"\n");
batchContent.append("yolo train model=\"").append(modelPath).append("\" data=\"").append(datasetPath)
.append("\" epochs=").append(epochs).append(" batch=").append(batchSize)
.append(" imgsz=").append(imageSize).append(" device=").append(device)
.append(" project=\"").append(outputPath).append("\" name=train").append(projectId).append("\"\n");
}
System.out.println("========================================");
System.out.println("YOLO训练开始");
System.out.println("========================================");
System.out.println("模型: " + modelPath);
System.out.println("数据集: " + datasetPath);
System.out.println("训练轮数: " + epochs);
System.out.println("批次大小: " + batchSize);
System.out.println("图像大小: " + imageSize);
System.out.println("设备: " + device);
System.out.println("输出路径: " + outputPath);
System.out.println("日志文件: " + logFilePath);
System.out.println("项目编号: " + projectId);
System.out.println("虚拟环境路径: " + venvPath);
System.out.println("操作系统: " + (isWindows ? "Windows" : "Linux"));
System.out.println("========================================");
try {
// 创建输出目录
Files.createDirectories(Paths.get(outputPath));
// 写入脚本文件
Files.write(Paths.get(batchFilePath), batchContent.toString().getBytes(StandardCharsets.UTF_8));
System.out.println("脚本文件已创建: " + batchFilePath);
// 如果是Linux,需要给脚本添加执行权限
if (!isWindows) {
new File(batchFilePath).setExecutable(true);
}
// 执行脚本文件
ProcessBuilder processBuilder;
if (isWindows) {
processBuilder = new ProcessBuilder("cmd", "/c", batchFilePath);
} else {
processBuilder = new ProcessBuilder(shellCommand, batchFilePath);
}
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
// 读取输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"));
BufferedWriter logWriter = new BufferedWriter(new FileWriter(logFilePath, StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
// 输出到控制台
System.out.println(line);
// 解析训练结果路径
if (line.contains("Results saved to ")) {
resultPath = line.substring(line.indexOf("Results saved to ") + 16).trim();
}
// 写入日志文件
logWriter.write(line);
logWriter.newLine();
}
logWriter.close();
reader.close();
// 等待进程结束
int exitCode = process.waitFor();
System.out.println("========================================");
if (exitCode == 0) {
System.out.println("训练完成!");
} else {
System.out.println("训练异常结束,退出码: " + exitCode);
}
System.out.println("日志已保存到: " + logFilePath);
System.out.println("训练结果路径: " + (resultPath != null ? resultPath : outputPath));
System.out.println("========================================");
} catch (Exception e) {
System.err.println("训练过程中发生错误: " + e.getMessage());
e.printStackTrace();
}
return resultPath;
}
@Override
public PythonVirtualEnvInfo activateVirtualEnv(String pythonPath, String envName) {
PythonVirtualEnvInfo info = new PythonVirtualEnvInfo();
try {
// 获取实际的Python可执行文件路径
String actualPythonPath = getActualPythonExecutable(pythonPath);
if (actualPythonPath == null) {
info.setPythonExists(false);
info.setErrorMessage("Python文件不存在或无法找到可执行文件: " + pythonPath);
return info;
}
// 检测Python版本
String version = getPythonVersion(pythonPath);
if (version == null) {
info.setPythonExists(false);
info.setErrorMessage("无法获取Python版本");
return info;
}
info.setPythonExists(true);
info.setPythonVersion(version);
info.setPythonPath(actualPythonPath);
// 检测虚拟环境
detectVirtualEnvironment(info, envName);
} catch (Exception e) {
log.error("激活虚拟环境失败", e);
info.setErrorMessage("激活虚拟环境异常: " + e.getMessage());
}
return info;
}
@Override
public PythonVirtualEnvInfo createVirtualEnvironment(String pythonPath, String envName) {
PythonVirtualEnvInfo info = new PythonVirtualEnvInfo();
try {
// 获取实际的Python可执行文件路径
String actualPythonPath = getActualPythonExecutable(pythonPath);
if (actualPythonPath == null) {
info.setPythonExists(false);
info.setErrorMessage("Python文件不存在或无法找到可执行文件: " + pythonPath);
return info;
}
// 获取Python目录
File pythonFile = new File(actualPythonPath);
String pythonDir = pythonFile.getParent();
// 构建虚拟环境路径
String venvPath = pythonDir + File.separator + envName;
File venvDir = new File(venvPath);
// 检查虚拟环境是否已存在
if (venvDir.exists()) {
log.info("虚拟环境已存在: {}", venvPath);
return detectPythonAndVirtualEnv(pythonPath, envName);
}
// 创建虚拟环境
String os = System.getProperty("os.name").toLowerCase();
String command;
if (os.contains("win")) {
command = actualPythonPath + " -m venv " + venvPath;
} else {
command = actualPythonPath + " -m venv " + venvPath;
}
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", command);
} else {
processBuilder.command("/bin/bash", "-c", command);
}
Process process = processBuilder.start();
int exitCode = process.waitFor();
if (exitCode == 0) {
log.info("虚拟环境创建成功: {}", venvPath);
// 重新检测环境
return detectPythonAndVirtualEnv(pythonPath, envName);
} else {
info.setErrorMessage("创建虚拟环境失败,退出码: " + exitCode);
}
} catch (Exception e) {
log.error("创建虚拟环境失败", e);
info.setErrorMessage("创建虚拟环境异常: " + e.getMessage());
}
return info;
}
@Override
public PythonVirtualEnvInfo installYolo(String pythonPath, String envName) {
PythonVirtualEnvInfo info = detectPythonAndVirtualEnv(pythonPath, envName);
if (!info.getVirtualEnvExists()) {
info.setErrorMessage("虚拟环境不存在,无法安装YOLO");
return info;
}
try {
// 在虚拟环境中安装YOLO,使用清华源
String os = System.getProperty("os.name").toLowerCase();
String command;
if (os.contains("win")) {
// Windows: 使用cmd激活虚拟环境后安装
command = info.getActivateCommand() + " && pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -U ultralytics";
} else {
// Linux/Mac: 使用bash激活虚拟环境后安装
command = info.getActivateCommand() + " pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -U ultralytics";
}
log.info("在虚拟环境中安装YOLO命令: {}", command);
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", command);
} else {
processBuilder.command("/bin/bash", "-c", command);
}
Process process = processBuilder.start();
// 读取输出
BufferedReader inputReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder output = new StringBuilder();
StringBuilder error = new StringBuilder();
String line;
while ((line = inputReader.readLine()) != null) {
output.append(line).append("\n");
}
while ((line = errorReader.readLine()) != null) {
error.append(line).append("\n");
}
int exitCode = process.waitFor();
inputReader.close();
errorReader.close();
if (exitCode == 0) {
log.info("YOLO安装成功: {}", output.toString());
// 重新检测环境,确认YOLO已安装
return detectPythonAndVirtualEnv(pythonPath, envName);
} else {
info.setErrorMessage("YOLO安装失败,退出码: " + exitCode + ", 错误信息: " + error);
}
} catch (Exception e) {
log.error("安装YOLO失败", e);
info.setErrorMessage("安装YOLO异常: " + e.getMessage());
}
return info;
}
@Override
public PythonVirtualEnvInfo detectPythonAndVirtualEnv(String pythonPath, String envName) {
PythonVirtualEnvInfo info = new PythonVirtualEnvInfo();
try {
// 获取实际的Python可执行文件路径
String actualPythonPath = getActualPythonExecutable(pythonPath);
if (actualPythonPath == null) {
info.setPythonExists(false);
info.setErrorMessage("Python文件不存在或无法找到可执行文件: " + pythonPath);
return info;
}
String version = getPythonVersion(pythonPath);
info.setPythonExists(true);
info.setPythonVersion(version);
info.setPythonPath(actualPythonPath);
// 如果指定了虚拟环境名称,则检测虚拟环境
if (envName != null && !envName.trim().isEmpty()) {
detectVirtualEnvironment(info, envName);
}
} catch (Exception e) {
log.error("检测Python和虚拟环境失败", e);
info.setErrorMessage("检测异常: " + e.getMessage());
}
return info;
}
private String getPythonVersion(String pythonPath) {
try {
// 检查并修正Python路径
String actualPythonPath = getActualPythonExecutable(pythonPath);
if (actualPythonPath == null) {
return null;
}
ProcessBuilder processBuilder = new ProcessBuilder();
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", actualPythonPath + " --version");
} else {
processBuilder.command("/bin/bash", "-c", actualPythonPath + " --version");
}
Process process = processBuilder.start();
// Python版本信息可能在标准输出或错误输出中,都检查一下
BufferedReader stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
BufferedReader stderrReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String stdoutLine = stdoutReader.readLine();
String stderrLine = stderrReader.readLine();
stdoutReader.close();
stderrReader.close();
// 检查两个输出流
String versionLine = null;
if (stdoutLine != null && (stdoutLine.toLowerCase().contains("python") || stdoutLine.matches("\\d+\\.\\d+\\.\\d+.*"))) {
versionLine = stdoutLine;
} else if (stderrLine != null && (stderrLine.toLowerCase().contains("python") || stderrLine.matches("\\d+\\.\\d+\\.\\d+.*"))) {
versionLine = stderrLine;
}
if (versionLine != null) {
return versionLine.trim();
}
} catch (Exception e) {
log.warn("获取Python版本失败: " + pythonPath, e);
}
return null;
}
/**
* 获取实际的Python可执行文件路径
*/
private String getActualPythonExecutable(String pythonPath) {
File file = new File(pythonPath);
// 如果是目录,尝试找到Python可执行文件
if (file.isDirectory()) {
String os = System.getProperty("os.name").toLowerCase();
String pythonExeName = os.contains("win") ? "python.exe" : "python3";
File pythonExe = new File(file, pythonExeName);
if (pythonExe.exists()) {
return pythonExe.getAbsolutePath();
}
// 在Windows上还尝试python3.exe
if (os.contains("win")) {
File python3Exe = new File(file, "python3.exe");
if (python3Exe.exists()) {
return python3Exe.getAbsolutePath();
}
}
return null;
}
// 如果是文件,直接返回
return file.exists() ? pythonPath : null;
}
private void detectVirtualEnvironment(PythonVirtualEnvInfo info, String envName) {
String os = System.getProperty("os.name").toLowerCase();
try {
// 1. 检测conda环境
// if (isCondaInstalled()) {
// String condaEnvPath = getCondaEnvPath(envName);
// if (condaEnvPath != null) {
// info.setVirtualEnvExists(true);
// info.setVirtualEnvType("conda");
// info.setVirtualEnvPath(condaEnvPath);
// info.setVirtualEnvPythonPath(getCondaEnvPythonPath(condaEnvPath));
// info.setActivateCommand("conda run -n " + envName);
//
// // 检测YOLO
// detectYoloInEnv(info);
// return;
// }
// }
// 2. 检测venv环境 - 专门检测Python目录下的虚拟环境
String pythonDir = info.getPythonPath();
if (pythonDir != null) {
// 如果是具体的python.exe路径,获取其所在目录
File pythonFile = new File(pythonDir);
if (pythonFile.isFile()) {
pythonDir = pythonFile.getParent();
}
// 构建虚拟环境路径:Python目录/envName
String venvPath = pythonDir + File.separator +"venv"+File.separator + envName;
File venvDir = new File(venvPath);
if (venvDir.exists() && venvDir.isDirectory()) {
// 检查是否是有效的虚拟环境
String venvPythonPath = getVenvEnvPythonPath(venvPath, os);
if (venvPythonPath != null && new File(venvPythonPath).exists()) {
info.setVirtualEnvExists(true);
info.setVirtualEnvType("venv");
info.setVirtualEnvPath(venvPath);
info.setVirtualEnvPythonPath(venvPythonPath);
info.setActivateCommand(os.contains("win") ?
venvPath + "\\Scripts\\activate && " :
"source " + venvPath + "/bin/activate && ");
// 检测YOLO
detectYoloInEnv(info);
return;
}
}
}
// 3. 检测virtualenv环境
// String virtualenvPath = getVirtualenvEnvPath(envName);
// if (virtualenvPath != null) {
// info.setVirtualEnvExists(true);
// info.setVirtualEnvType("virtualenv");
// info.setVirtualEnvPath(virtualenvPath);
// info.setVirtualEnvPythonPath(getVenvEnvPythonPath(virtualenvPath, os));
// info.setActivateCommand(os.contains("win") ?
// virtualenvPath + "\\Scripts\\activate" :
// "source " + virtualenvPath + "/bin/activate");
//
// // 检测YOLO
// detectYoloInEnv(info);
// return;
// }
info.setVirtualEnvExists(false);
info.setErrorMessage("未找到虚拟环境: " + envName);
} catch (Exception e) {
log.error("检测虚拟环境失败: " + envName, e);
info.setVirtualEnvExists(false);
info.setErrorMessage("检测虚拟环境异常: " + e.getMessage());
}
}
private boolean isCondaInstalled() {
try {
String os = System.getProperty("os.name").toLowerCase();
String command = "conda --version";
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", command);
} else {
processBuilder.command("/bin/bash", "-c", command);
}
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
String line = reader.readLine();
reader.close();
return line != null && line.toLowerCase().contains("conda");
} catch (Exception e) {
return false;
}
}
private String getCondaEnvPath(String envName) {
try {
String os = System.getProperty("os.name").toLowerCase();
String command = "conda env list";
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", command);
} else {
processBuilder.command("/bin/bash", "-c", command);
}
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (!line.trim().isEmpty() && !line.startsWith("#") && line.contains(envName)) {
String[] parts = line.split("\\s+");
if (parts.length >= 2) {
reader.close();
return parts[1].trim();
}
}
}
reader.close();
} catch (Exception e) {
log.warn("获取conda环境路径失败: " + envName, e);
}
return null;
}
private String getCondaEnvPythonPath(String condaEnvPath) {
String os = System.getProperty("os.name").toLowerCase();
return os.contains("win") ?
condaEnvPath + "\\python.exe" :
condaEnvPath + "/bin/python";
}
private String getVenvEnvPath(String pythonPath, String envName) {
// 检查 pythonPath\venv 目录下是否存在 envName 文件夹
File venvBaseDir = new File(pythonPath, "venv");
if (venvBaseDir.exists() && venvBaseDir.isDirectory()) {
File targetEnvDir = new File(venvBaseDir, envName);
if (targetEnvDir.exists() && targetEnvDir.isDirectory()) {
return targetEnvDir.getAbsolutePath();
}
}
// 检查当前目录下的常见虚拟环境目录
String[] commonDirs = {envName, "venv", ".venv", "env", ".env"};
for (String dir : commonDirs) {
File venvDir = new File(dir);
if (venvDir.exists() && venvDir.isDirectory()) {
return venvDir.getAbsolutePath();
}
}
// 检查当前目录下的常见虚拟环境目录
// 检查用户主目录下的虚拟环境
String userHome = System.getProperty("user.home");
File virtualenvsDir = new File(userHome, ".virtualenvs");
if (virtualenvsDir.exists()) {
File envDir = new File(virtualenvsDir, envName);
if (envDir.exists()) {
return envDir.getAbsolutePath();
}
}
return null;
}
private String getVirtualenvEnvPath(String envName) {
// 主要检查.virtualenvs目录
String userHome = System.getProperty("user.home");
File virtualenvsDir = new File(userHome, ".virtualenvs");
if (virtualenvsDir.exists()) {
File envDir = new File(virtualenvsDir, envName);
if (envDir.exists()) {
return envDir.getAbsolutePath();
}
}
return null;
}
private String getVenvEnvPythonPath(String venvPath, String os) {
return os.contains("win") ?
venvPath + "\\Scripts\\python.exe" :
venvPath + "/bin/python";
}
private void detectYoloInEnv(PythonVirtualEnvInfo info) {
if (info.getVirtualEnvPythonPath() == null) {
return;
}
try {
// 在虚拟环境中检测ultralytics包
String os = System.getProperty("os.name").toLowerCase();
String command;
if ("conda".equals(info.getVirtualEnvType())) {
command = "conda run -n " + new File(info.getVirtualEnvPath()).getName() + " python -c \"import ultralytics; print(ultralytics.__version__)\"";
} else if (os.contains("win")) {
command = info.getActivateCommand() + " python -c \"import ultralytics; print(ultralytics.__version__)\"";
} else {
command = info.getActivateCommand() + " python -c \"import ultralytics; print(ultralytics.__version__)\"";
}
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd", "/c", command);
} else {
processBuilder.command("/bin/bash", "-c", command);
}
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = reader.readLine();
reader.close();
if (line != null && !line.trim().isEmpty()) {
info.setYoloInstalled(true);
info.setYoloVersion(line.trim());
} else {
info.setYoloInstalled(false);
}
} catch (Exception e) {
log.warn("检测YOLO安装状态失败", e);
info.setYoloInstalled(false);
}
}
public static void main(String[] args) {
}
}

@ -1,68 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.python.vo;
import lombok.Data;
/**
* Python虚拟环境信息
*
* @author 管理员
*/
@Data
public class PythonVirtualEnvInfo {
/**
* Python是否存在
*/
private Boolean pythonExists;
/**
* Python版本
*/
private String pythonVersion;
/**
* Python路径
*/
private String pythonPath;
/**
* 虚拟环境是否存在
*/
private Boolean virtualEnvExists;
/**
* 虚拟环境类型 (venv/virtualenv/conda)
*/
private String virtualEnvType;
/**
* 虚拟环境路径
*/
private String virtualEnvPath;
/**
* 虚拟环境中Python路径
*/
private String virtualEnvPythonPath;
/**
* 是否安装了YOLO
*/
private Boolean yoloInstalled;
/**
* YOLO版本
*/
private String yoloVersion;
/**
* 激活命令(用于后续操作)
*/
private String activateCommand;
/**
* 错误信息
*/
private String errorMessage;
}

@ -1,13 +1,11 @@
package cn.iocoder.yudao.module.annotation.service.train;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.annotation.controller.admin.train.vo.SystemInfoRespVO;
import cn.iocoder.yudao.module.annotation.controller.admin.train.vo.TrainPageReqVO;
import cn.iocoder.yudao.module.annotation.controller.admin.train.vo.TrainSaveReqVO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.train.TrainDO;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@ -57,14 +55,8 @@ public interface TrainService extends IService<TrainDO> {
*/
PageResult<TrainDO> getTrainPage(TrainPageReqVO pageReqVO);
/**
* 获取系统信息(CPU、GPU、Python环境)
*
* @return 系统信息
*/
SystemInfoRespVO getSystemInfo();
public boolean init(TrainSaveReqVO createReqVO);
boolean init(TrainSaveReqVO createReqVO);
List<String> testImages();

@ -9,21 +9,16 @@ import cn.iocoder.yudao.module.annotation.dal.dataobject.datas.DatasDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.mark.MarkInfoDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.train.TrainDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.trainresult.TrainResultDO;
import cn.iocoder.yudao.module.annotation.dal.dataobject.types.TypesDO;
import cn.iocoder.yudao.module.annotation.dal.mysql.train.TrainMapper;
import cn.iocoder.yudao.module.annotation.dal.mysql.trainresult.TrainResultMapper;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig;
import cn.iocoder.yudao.module.annotation.dal.dataobject.types.TypesDO;
import cn.iocoder.yudao.module.annotation.dal.mysql.train.TrainMapper;
import cn.iocoder.yudao.module.annotation.service.MarkInfo.AnnotationData;
import cn.iocoder.yudao.module.annotation.service.MarkInfo.MarkInfoService;
import cn.iocoder.yudao.module.annotation.service.datas.DatasService;
import cn.iocoder.yudao.module.annotation.service.mark.MarkService;
import cn.iocoder.yudao.module.annotation.service.types.TypesService;
import cn.iocoder.yudao.module.annotation.service.yolo.YoloOperationService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@ -33,7 +28,6 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
@ -96,8 +90,6 @@ public class TrainServiceImpl extends ServiceImpl<TrainMapper, TrainDO> implemen
return train.getId();
}
@Resource
YoloOperationService yoloOperationService;
public boolean init(TrainSaveReqVO createReqVO) {
try {
TrainDO train = trainMapper.selectById(createReqVO.getId());
@ -134,7 +126,7 @@ public class TrainServiceImpl extends ServiceImpl<TrainMapper, TrainDO> implemen
generateYamlConfig(typesList, taskType, yoloDatasetPath);
update( new UpdateWrapper<TrainDO>().eq("id", createReqVO.getId()).set("train_type", 6));
yoloOperationService.generateTrainPythonScript(train, yoloDatasetPath);
// yoloOperationService.generateTrainPythonScript(train, yoloDatasetPath);
log.info("YOLO数据集生成完成: {}", yoloDatasetPath);
return true;
@ -250,9 +242,9 @@ public class TrainServiceImpl extends ServiceImpl<TrainMapper, TrainDO> implemen
yoloConfig.setModelPath(trainResultDO.getPath()+"/weights/best.pt");
yoloConfig.setInputPath(dictDataMap.get("base_path").getValue() + "test/" + image);
yoloConfig.setOutputPath(dictDataMap.get("base_path").getValue() + "test/run/");
YoloConfig yoloConfigResult = yoloOperationService.predict(dictDataMap.get("python_path").getValue(),dictDataMap.get("python_venv").getValue(),yoloConfig);
String url = replacePathPrefix(yoloConfigResult.getOutputPath(), dictDataMap.get("base_path").getValue(), dictDataMap.get("base_url").getValue()) + "/" + image;
return url;
// YoloConfig yoloConfigResult = yoloOperationService.predict(dictDataMap.get("python_path").getValue(),dictDataMap.get("python_venv").getValue(),yoloConfig);
// String url = replacePathPrefix(yoloConfigResult.getOutputPath(), dictDataMap.get("base_path").getValue(), dictDataMap.get("base_url").getValue()) + "/" + image;
return "";
}
/**
* 智能路径替换方法
@ -674,118 +666,6 @@ public class TrainServiceImpl extends ServiceImpl<TrainMapper, TrainDO> implemen
return trainMapper.selectPage(pageReqVO);
}
@Override
public SystemInfoRespVO getSystemInfo() {
SystemInfoRespVO systemInfo = new SystemInfoRespVO();
// 获取CPU信息
systemInfo.setCpu(getCpuInfo());
// 获取GPU信息
systemInfo.setGpus(getGpuInfo());
// 获取Python信息
systemInfo.setPython(getPythonInfo());
return systemInfo;
}
private SystemInfoRespVO.CpuInfo getCpuInfo() {
SystemInfoRespVO.CpuInfo cpuInfo = new SystemInfoRespVO.CpuInfo();
try {
// 获取CPU型号
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
// Windows系统
Process process = Runtime.getRuntime().exec("wmic cpu get name");
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (!line.trim().isEmpty() && !line.contains("Name")) {
cpuInfo.setModel(line.trim());
break;
}
}
reader.close();
// 获取CPU核心数
process = Runtime.getRuntime().exec("wmic cpu get numberofcores");
reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
while ((line = reader.readLine()) != null) {
if (!line.trim().isEmpty() && !line.contains("NumberOfCores")) {
cpuInfo.setCores(Integer.parseInt(line.trim()));
break;
}
}
reader.close();
} else {
// Linux系统
Process process = Runtime.getRuntime().exec("cat /proc/cpuinfo | grep 'model name' | head -1");
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = reader.readLine();
if (line != null) {
cpuInfo.setModel(line.split(":")[1].trim());
}
reader.close();
// 获取CPU核心数
process = Runtime.getRuntime().exec("nproc");
reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
line = reader.readLine();
if (line != null) {
cpuInfo.setCores(Integer.parseInt(line.trim()));
}
reader.close();
}
// 获取CPU使用率(简化实现)
cpuInfo.setUsage(0.0);
} catch (Exception e) {
log.error("获取CPU信息失败", e);
cpuInfo.setModel("Unknown");
cpuInfo.setCores(0);
cpuInfo.setUsage(0.0);
}
return cpuInfo;
}
private List<SystemInfoRespVO.GpuInfo> getGpuInfo() {
List<SystemInfoRespVO.GpuInfo> gpuList = new ArrayList<>();
try {
// 使用nvidia-smi获取GPU信息
Process process = Runtime.getRuntime().exec("nvidia-smi --query-gpu=index,name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu --format=csv,noheader,nounits");
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (!line.trim().isEmpty()) {
String[] parts = line.split(",\\s*");
if (parts.length >= 7) {
SystemInfoRespVO.GpuInfo gpuInfo = new SystemInfoRespVO.GpuInfo();
gpuInfo.setId(Integer.parseInt(parts[0].trim()));
gpuInfo.setName(parts[1].trim());
gpuInfo.setMemoryTotal(Long.parseLong(parts[2].trim()));
gpuInfo.setMemoryUsed(Long.parseLong(parts[3].trim()));
gpuInfo.setMemoryFree(Long.parseLong(parts[4].trim()));
gpuInfo.setUsage(Double.parseDouble(parts[5].trim()));
gpuInfo.setTemperature(Double.parseDouble(parts[6].trim()));
gpuList.add(gpuInfo);
}
}
}
reader.close();
} catch (Exception e) {
log.error("获取GPU信息失败", e);
// 如果nvidia-smi不可用,返回空列表
}
return gpuList;
}
public static void main(String[] args) {

@ -1,13 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
// AsyncYoloService.java
import cn.iocoder.yudao.module.annotation.dal.yolo.*;
import java.util.concurrent.CompletableFuture;
public interface AsyncYoloService extends YoloService {
CompletableFuture<TrainingResult> trainAsync(YoloConfig config);
CompletableFuture<ValidationResult> validateAsync(YoloConfig config);
CompletableFuture<PredictionResult> predictAsync(YoloConfig config, String imagePath);
CompletableFuture<ExportResult> exportAsync(YoloConfig config, String format);
}

@ -1,205 +0,0 @@
// AsyncYoloServiceImpl.java
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.*;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.concurrent.CompletableFuture;
@Service
public class AsyncYoloServiceImpl implements AsyncYoloService {
@Override
@Async("yoloTaskExecutor")
public CompletableFuture<TrainingResult> trainAsync(YoloConfig config) {
return CompletableFuture.supplyAsync(() -> {
try {
ProcessBuilder pb = buildTrainCommand(config);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
System.out.println(line); // 可用于日志记录
}
int exitCode = process.waitFor();
if (exitCode == 0) {
return new TrainingResult(true, "Training completed successfully", output.toString());
} else {
return new TrainingResult(false, "Training failed with exit code: " + exitCode, output.toString());
}
} catch (Exception e) {
return new TrainingResult(false, "Training error: " + e.getMessage(), "");
}
});
}
@Override
@Async("yoloTaskExecutor")
public CompletableFuture<ValidationResult> validateAsync(YoloConfig config) {
return CompletableFuture.supplyAsync(() -> {
try {
ProcessBuilder pb = buildValidateCommand(config);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new ValidationResult(exitCode == 0, output.toString());
} catch (Exception e) {
return new ValidationResult(false, "Validation error: " + e.getMessage());
}
});
}
@Override
@Async("yoloTaskExecutor")
public CompletableFuture<PredictionResult> predictAsync(YoloConfig config, String imagePath) {
return CompletableFuture.supplyAsync(() -> {
try {
ProcessBuilder pb = buildPredictCommand(config, imagePath);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new PredictionResult(exitCode == 0, output.toString(), imagePath);
} catch (Exception e) {
return new PredictionResult(false, "Prediction error: " + e.getMessage(), imagePath);
}
});
}
@Override
@Async("yoloTaskExecutor")
public CompletableFuture<ExportResult> exportAsync(YoloConfig config, String format) {
return CompletableFuture.supplyAsync(() -> {
try {
ProcessBuilder pb = buildExportCommand(config, format);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new ExportResult(exitCode == 0, output.toString(), format);
} catch (Exception e) {
return new ExportResult(false, "Export error: " + e.getMessage(), format);
}
});
}
// 同步方法实现(可选)
@Override
public TrainingResult train(YoloConfig config) {
return trainAsync(config).join();
}
@Override
public ValidationResult validate(YoloConfig config) {
return validateAsync(config).join();
}
@Override
public PredictionResult predict(YoloConfig config, String imagePath) {
return predictAsync(config, imagePath).join();
}
@Override
public ExportResult export(YoloConfig config, String format) {
return exportAsync(config, format).join();
}
private ProcessBuilder buildTrainCommand(YoloConfig config) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "train",
"--model", config.getModelPath(),
"--data", config.getDatasetPath(),
"--epochs", String.valueOf(config.getEpochs()),
"--batch", String.valueOf(config.getBatchSize()),
"--lr", String.valueOf(config.getLearningRate()),
"--device", config.getDevice(),
"--project", config.getOutputPath()
);
if (config.isPretrained()) {
pb.command().add("--pretrained");
pb.command().add("True");
}
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildValidateCommand(YoloConfig config) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "val",
"--model", config.getModelPath(),
"--data", config.getDatasetPath(),
"--device", config.getDevice()
);
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildPredictCommand(YoloConfig config, String imagePath) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "predict",
"--model", config.getModelPath(),
"--source", imagePath,
"--device", config.getDevice(),
"--project", config.getOutputPath()
);
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildExportCommand(YoloConfig config, String format) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "export",
"--model", config.getModelPath(),
"--format", format
);
pb.redirectErrorStream(true);
return pb;
}
}

@ -1,267 +0,0 @@
# 如何在Spring环境中使用YOLO训练
## 问题说明
如果你直接 `new YoloOperationServiceImpl()`,会遇到 `NullPointerException` 错误,因为所有通过 `@Resource` 注解注入的依赖都是 `null`。
## 解决方案
### 方式1:运行 YoloTrainMain.java(推荐)
`YoloTrainMain.java` 已经修改为自动启动Spring环境并正确注入依赖。
**步骤:**
1. 打开 `YoloTrainMain.java`
2. 确保主启动类路径正确(第32行)
3. 修改配置参数(Python路径、虚拟环境、数据集等)
4. 直接运行 `main` 方法
**代码示例:**
```java
public static void main(String[] args) {
// 启动Spring应用上下文
ConfigurableApplicationContext context = new SpringApplicationBuilder(
cn.iocoder.yudao.server.YudaoServerApplication.class) // 确保这个路径正确
.headless(false)
.run(args);
// 从Spring容器获取YoloOperationService
YoloOperationService yoloService = context.getBean(YoloOperationService.class);
// 配置参数...
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelPath("D:/models/model.pt");
config.setDatasetPath("D:/datasets/data.yaml");
// ... 其他配置
// 启动训练
CompletableFuture<TrainResult> future =
yoloService.trainAsyncWithConfig("python", "yolo", config, "/path/to/log.txt");
// 等待完成
future.join();
// 关闭Spring上下文
context.close();
}
```
### 方式2:在Spring Service中使用(推荐用于生产环境)
如果你的代码已经在Spring环境中运行,直接注入即可。
**代码示例:**
```java
@Service
public class MyTrainingService {
@Resource
private YoloOperationService yoloOperationService;
public void startTraining() {
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelPath("D:/models/model.pt");
config.setDatasetPath("D:/datasets/data.yaml");
config.setEpochs(100);
config.setBatchSize(16);
config.setOutputPath("D:/yolo_output");
String logFilePath = "D:/yolo_output/training_log.txt";
CompletableFuture<TrainResult> future =
yoloOperationService.trainAsyncWithConfig("python", "yolo", config, logFilePath);
// 异步处理结果
future.thenAccept(result -> {
if ("completed".equals(result.getStatus())) {
log.info("训练成功完成");
log.info("导出路径: {}", result.getExportPath());
} else {
log.error("训练失败: {}", result.getErrorMessage());
}
});
}
}
```
### 方式3:在Controller中使用(Web应用)
如果通过Web API调用训练功能:
```java
@RestController
@RequestMapping("/api/yolo")
public class YoloController {
@Resource
private YoloOperationService yoloOperationService;
@PostMapping("/train")
public CommonResult<String> startTraining(@RequestBody YoloConfig config) {
try {
// 设置默认参数
if (config.getEpochs() == 0) {
config.setEpochs(100);
}
if (config.getBatchSize() == 0) {
config.setBatchSize(16);
}
String logFilePath = config.getOutputPath() + "/training_log.txt";
// 启动异步训练
CompletableFuture<TrainResult> future =
yoloOperationService.trainAsyncWithConfig(
"python", "yolo", config, logFilePath);
// 返回任务ID
return success(future.get().getTaskId());
} catch (Exception e) {
return fail("训练启动失败: " + e.getMessage());
}
}
}
```
## 常见问题
### Q1: 启动时找不到 YudaoServerApplication 类?
**A:** 修改 `YoloTrainMain.java` 第32行,改为你的主启动类路径:
```java
// 修改为你的主启动类
ConfigurableApplicationContext context = new SpringApplicationBuilder(
com.your.package.YourApplication.class) // 修改这里
.headless(false)
.run(args);
```
### Q2: 如何查找主启动类?
**A:** 主启动类通常有 `@SpringBootApplication` 注解,例如:
```java
@SpringBootApplication
public class YudaoServerApplication {
public static void main(String[] args) {
SpringApplication.run(YudaoServerApplication.class, args);
}
}
```
### Q3: 可以不启动Spring环境吗?
**A:** 不推荐。`YoloOperationServiceImpl` 依赖多个服务(PythonVirtualEnvService, TrainService等),这些服务都需要Spring容器管理。
### Q4: 训练任务会阻塞主线程吗?
**A:** 不会。`trainAsyncWithConfig` 返回 `CompletableFuture`,训练在异步线程中执行。
### Q5: 如何查看训练进度?
**A:** 可以通过以下方式:
1. 查看日志文件 `training_log.txt`
2. 使用 `TrainResult` 对象的 `progress` 字段(需要通过轮询或回调)
3. 添加一个查询接口,返回训练状态
## 完整示例:Spring Controller + 训练进度查询
```java
@RestController
@RequestMapping("/api/yolo")
public class YoloTrainingController {
@Resource
private YoloOperationService yoloOperationService;
// 存储训练任务(生产环境建议使用数据库或Redis)
private static final Map<String, TrainResult> trainingTasks = new ConcurrentHashMap<>();
/**
* 启动训练
*/
@PostMapping("/train/start")
public CommonResult<String> startTraining(@RequestBody YoloConfig config) {
try {
// 参数校验
if (config.getDatasetPath() == null || config.getDatasetPath().isEmpty()) {
return fail("数据集路径不能为空");
}
// 设置日志文件路径
String logFilePath = config.getOutputPath() + "/training_log_" +
System.currentTimeMillis() + ".txt";
// 启动异步训练
CompletableFuture<TrainResult> future =
yoloOperationService.trainAsyncWithConfig(
"python", "yolo", config, logFilePath);
// 异步处理结果
future.thenAccept(result -> {
String taskId = result.getTaskId();
trainingTasks.put(taskId, result);
if ("completed".equals(result.getStatus())) {
log.info("训练完成: taskId={}, exportPath={}",
taskId, result.getExportPath());
} else if ("failed".equals(result.getStatus())) {
log.error("训练失败: taskId={}, error={}",
taskId, result.getErrorMessage());
}
});
// 返回任务ID(先使用临时ID,实际应该从future中获取)
String taskId = UUID.randomUUID().toString();
return success(taskId);
} catch (Exception e) {
return fail("训练启动失败: " + e.getMessage());
}
}
/**
* 查询训练进度
*/
@GetMapping("/train/progress/{taskId}")
public CommonResult<TrainResult> getProgress(@PathVariable String taskId) {
TrainResult result = trainingTasks.get(taskId);
if (result == null) {
return fail("任务不存在");
}
return success(result);
}
/**
* 获取训练日志
*/
@GetMapping("/train/log/{taskId}")
public CommonResult<String> getLog(@PathVariable String taskId) {
TrainResult result = trainingTasks.get(taskId);
if (result == null) {
return fail("任务不存在");
}
try {
String logFilePath = "D:/yolo_output/training_log_" + taskId + ".txt";
String logContent = Files.readString(Path.of(logFilePath));
return success(logContent);
} catch (Exception e) {
return fail("读取日志失败: " + e.getMessage());
}
}
}
```
## 总结
✅ **推荐方式**:使用 `YoloTrainMain.java`,它已经配置好Spring环境
✅ **生产环境**:在你的Service或Controller中注入 `YoloOperationService`
✅ **不要**:直接 `new YoloOperationServiceImpl()`
如有问题,请检查:
1. 主启动类路径是否正确
2. Spring配置是否正确
3. 依赖的服务是否都正常启动

@ -1,312 +0,0 @@
# YOLO训练使用说明
## 概述
新的YOLO训练方法 `trainAsyncWithConfig` 提供了更灵活的配置选项,支持检测和分类任务,并改进了日志处理。
## 主要特性
1. **支持多种任务类型**
- 目标检测 (detect)
- 图像分类 (classify)
- 实例分割 (segment)
2. **灵活的模型配置**
- 使用预训练模型 (yolov8n, yolov8s, etc.)
- 使用自定义模型文件 (.pt 或 .yaml)
3. **完整的训练参数**
- epochs, batch, imgsz
- learningRate, device, optimizer
- workers, savePeriod
4. **改进的日志处理**
- 自动过滤进度条信息
- 保留每轮批次信息
- 写入到txt文件
5. **导出路径记录**
- 自动保存模型导出文件夹路径
## 使用方法
### 方法一:直接运行 Main 函数
1. 打开 `YoloTrainMain.java`
2. 修改配置参数
3. 运行 `main` 方法
```java
public static void main(String[] args) {
// 配置参数
String pythonPath = "python";
String envName = "yolo";
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelName("yolov8n");
config.setDatasetPath("D:/datasets/dataset.yaml");
config.setEpochs(100);
config.setBatchSize(16);
config.setImageSize(640);
config.setOutputPath("D:/yolo_output");
String logFilePath = "D:/yolo_output/training_log.txt";
// 执行训练
YoloOperationServiceImpl service = new YoloOperationServiceImpl();
CompletableFuture<TrainResult> future =
service.trainAsyncWithConfig(pythonPath, envName, config, logFilePath);
// 等待结果
future.thenAccept(result -> {
System.out.println("训练完成: " + result.getStatus());
System.out.println("导出路径: " + result.getExportPath());
});
future.join();
}
```
### 方法二:在Spring中使用
```java
@Service
public class MyTrainingService {
@Resource
private YoloOperationService yoloOperationService;
public void startTraining() {
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelName("yolov8n");
config.setDatasetPath("/path/to/dataset.yaml");
config.setEpochs(100);
config.setBatchSize(16);
config.setImageSize(640);
config.setOutputPath("/path/to/output");
String logFilePath = "/path/to/training_log.txt";
CompletableFuture<TrainResult> future =
yoloOperationService.trainAsyncWithConfig("python", "yolo", config, logFilePath);
// 异步处理结果
future.thenAccept(result -> {
if ("completed".equals(result.getStatus())) {
log.info("训练成功完成");
log.info("导出路径: {}", result.getExportPath());
log.info("最终指标: {}", result.getMetrics());
} else {
log.error("训练失败: {}", result.getErrorMessage());
}
});
}
}
```
## 参数配置详解
### 任务类型 (taskType)
- **detect**: 目标检测任务
- **classify**: 图像分类任务
- **segment**: 实例分割任务
### 模型配置
**方式1: 预训练模型**
```java
config.setModelName("yolov8n"); // n, s, m, l, x
```
**方式2: 自定义模型**
```java
config.setModelPath("D:/models/custom_model.pt");
```
### 数据集配置
数据集配置文件 (.yaml) 应包含:
- 训练和验证数据路径
- 类别名称和数量
- 数据集路径
```yaml
# dataset.yaml 示例
path: /path/to/dataset
train: images/train
val: images/val
names:
0: person
1: car
2: dog
```
### 训练参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| epochs | int | 100 | 训练轮数 |
| batchSize | int | 16 | 批次大小 |
| imageSize | int | 640 | 图像大小 |
| learningRate | double | 0.01 | 学习率 |
| device | String | "0" | 设备配置 |
| optimizer | String | "SGD" | 优化器 |
| workers | int | 8 | 工作线程数 |
| savePeriod | int | -1 | 保存间隔 |
### 设备配置
- `"0"`, `"1"`, `"2"`: 使用指定GPU
- `"0,1"`, `"0,1,2"`: 使用多GPU
- `"cpu"`: 使用CPU
## 日志说明
### 自动过滤
- ✅ 保留:每轮批次信息
- ✅ 保留:训练指标(mAP, precision, recall)
- ❌ 过滤:进度条信息(包含%的行,但保留100%)
- ✅ 保留:模型保存路径信息
### 日志文件
日志文件包含:
- 训练开始时间
- 每轮训练信息(不包含进度条)
- 最终训练指标
- 模型保存路径
- 训练完成时间
## 训练结果
训练完成后,可以通过 `TrainResult` 对象获取:
```java
TrainResult result = future.get();
// 状态信息
String status = result.getStatus(); // pending, running, completed, failed
int progress = result.getProgress(); // 0-100
String errorMessage = result.getErrorMessage(); // 错误信息(如果有)
// 训练指标
int currentEpoch = result.getCurrentEpoch(); // 当前轮次
double loss = result.getLoss(); // 损失值
double precision = result.getPrecision(); // 精度
double recall = result.getRecall(); // 召回率
double map = result.getMap(); // mAP值
// 输出信息
String exportPath = result.getExportPath(); // 模型导出路径
String metrics = result.getMetrics(); // 详细指标
String trainingSummary = result.getTrainingSummary(); // 训练摘要
```
## 示例场景
### 场景1: 目标检测训练
```java
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelName("yolov8s");
config.setDatasetPath("D:/datasets/coco.yaml");
config.setEpochs(100);
config.setBatchSize(16);
config.setImageSize(640);
config.setDevice("0");
```
### 场景2: 图像分类训练
```java
YoloConfig config = new YoloConfig();
config.setTaskType("classify");
config.setModelName("yolov8n-cls.pt");
config.setDatasetPath("D:/datasets/imagenet.yaml");
config.setEpochs(50);
config.setBatchSize(32);
config.setImageSize(224);
config.setDevice("0");
```
### 场景3: 使用自定义模型
```java
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelPath("D:/models/my_custom_model.pt");
config.setDatasetPath("D:/datasets/custom.yaml");
config.setEpochs(100);
config.setBatchSize(8);
config.setImageSize(1024);
config.setLearningRate(0.001);
config.setOptimizer("Adam");
```
### 场景4: 多GPU训练
```java
YoloConfig config = new YoloConfig();
config.setTaskType("detect");
config.setModelName("yolov8x");
config.setDatasetPath("/datasets/large_dataset.yaml");
config.setEpochs(100);
config.setBatchSize(32);
config.setImageSize(1280);
config.setDevice("0,1,2,3"); // 使用4个GPU
```
## 常见问题
### Q1: 如何查看训练进度?
A: 日志文件中会显示每轮的训练信息,但不包含进度条。可以通过 `TrainResult` 对象的 `progress` 字段查看总体进度。
### Q2: 如何恢复中断的训练?
A: 设置 `savePeriod` 参数来定期保存模型,然后使用保存的模型文件继续训练:
```java
config.setModelPath("D:/yolo_output/train/weights/last.pt");
config.setEpochs(50); // 继续训练50轮
```
### Q3: 如何调整学习率?
A: 设置 `learningRate` 参数:
```java
config.setLearningRate(0.001); // 降低学习率
```
### Q4: 如何在Linux系统上使用?
A: 修改 Python 路径和虚拟环境名称:
```java
String pythonPath = "/usr/bin/python3";
String envName = "yolo_env";
```
### Q5: 日志文件太大怎么办?
A: 该方法已自动过滤进度条信息,大大减少了日志大小。如果还需要进一步减少,可以:
- 增加 `savePeriod` 减少日志输出频率
- 使用 `verbose=False` 参数(需要在命令中添加)
## 注意事项
1. 确保Python虚拟环境已安装YOLO
2. 确保数据集配置文件路径正确
3. 确保输出路径有写入权限
4. GPU训练时确保有足够的显存
5. 大型数据集建议使用更大的batch size和workers
## 技术支持
如有问题,请查看:
1. 日志文件中的错误信息
2. YOLO官方文档
3. 项目README文档

@ -1,87 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.dataobject.train.TrainDO;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig;
import java.util.concurrent.CompletableFuture;
/**
* YOLO操作服务接口
*
* @author 管理员
*/
public interface YoloOperationService {
/**
* YOLO训练(异步)
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 训练配置
* @return 异步训练结果
*/
CompletableFuture<YoloConfig> trainAsync(String pythonPath, String envName, YoloConfig config);
/**
* YOLO训练(异步,新方法,支持检测和分类任务)
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 训练配置
* @param logFilePath 日志文件路径
* @return 异步训练结果
*/
CompletableFuture<YoloOperationServiceImpl.TrainResult> trainAsyncWithConfig(String pythonPath, String envName, YoloConfig config, String logFilePath);
/**
* YOLO验证
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 验证配置
* @return 验证结果配置
*/
YoloConfig validate(String pythonPath, String envName, YoloConfig config);
/**
* YOLO预测
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 预测配置
* @return 预测结果配置
*/
YoloConfig predict(String pythonPath, String envName, YoloConfig config);
/**
* YOLO分类
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 分类配置
* @return 分类结果配置
*/
YoloConfig classify(String pythonPath, String envName, YoloConfig config);
/**
* 导出YOLO模型
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 导出配置
* @return 导出结果配置
*/
YoloConfig export(String pythonPath, String envName, YoloConfig config);
/**
* 追踪任务
*
* @param pythonPath Python路径
* @param envName 虚拟环境名称
* @param config 追踪配置
* @return 追踪结果配置
*/
YoloConfig track(String pythonPath, String envName, YoloConfig config);
void generateTrainPythonScript(TrainDO train, String yoloDatasetPath);
}

@ -1,9 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloProgress;
public interface YoloProgressListener {
void onProgressUpdate(YoloProgress progress);
void onComplete(YoloProgress finalProgress);
void onError(Exception e);
}

@ -1,60 +0,0 @@
// YoloProgressParser.java
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloProgress;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class YoloProgressParser {
// 匹配训练进度的正则表达式
private static final Pattern EPOCH_PATTERN = Pattern.compile("Epoch\\s+(\\d+)/(\\d+)");
private static final Pattern PROGRESS_PATTERN = Pattern.compile("(\\d+)/(\\d+)\\s+\\[.*?([0-9.]+)%.*?\\]");
private static final Pattern LOSS_PATTERN = Pattern.compile("loss:\\s*([0-9.]+)");
public static YoloProgress parseLine(String line, YoloProgress currentProgress) {
if (currentProgress == null) {
currentProgress = new YoloProgress();
}
// 解析Epoch信息
Matcher epochMatcher = EPOCH_PATTERN.matcher(line);
if (epochMatcher.find()) {
currentProgress.setCurrentEpoch(Integer.parseInt(epochMatcher.group(1)));
currentProgress.setTotalEpochs(Integer.parseInt(epochMatcher.group(2)));
currentProgress.setStatusMessage("Training epoch " +
currentProgress.getCurrentEpoch() + "/" + currentProgress.getTotalEpochs());
}
// 解析进度条信息
Matcher progressMatcher = PROGRESS_PATTERN.matcher(line);
if (progressMatcher.find()) {
currentProgress.setProcessedItems(Integer.parseInt(progressMatcher.group(1)));
currentProgress.setTotalItems(Integer.parseInt(progressMatcher.group(2)));
currentProgress.setProgress(Double.parseDouble(progressMatcher.group(3)));
}
// 解析Loss信息
Matcher lossMatcher = LOSS_PATTERN.matcher(line);
if (lossMatcher.find()) {
currentProgress.setStatusMessage(currentProgress.getStatusMessage() +
" | Loss: " + lossMatcher.group(1));
}
return currentProgress;
}
public static boolean isCompletionIndicator(String line) {
return line.contains("Training completed") ||
line.contains("completed successfully") ||
line.contains("finished");
}
public static boolean isErrorIndicator(String line) {
return line.contains("error") ||
line.contains("failed") ||
line.contains("exception") ||
line.contains("Error");
}
}

@ -1,26 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.*;
// YoloService.java
public interface YoloService {
/**
* 训练模型
*/
TrainingResult train(YoloConfig config);
/**
* 验证模型
*/
ValidationResult validate(YoloConfig config);
/**
* 进行预测
*/
PredictionResult predict(YoloConfig config, String imagePath);
/**
* 导出模型
*/
ExportResult export(YoloConfig config, String format);
}

@ -1,219 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
import cn.iocoder.yudao.module.annotation.dal.yolo.YoloConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.*;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* YOLO服务客户端 - 通过socket与Python长连接服务通信
*
* @author 管理员
*/
@Slf4j
@Component
public class YoloServiceClient {
private static final String DEFAULT_HOST = "localhost";
private static final int DEFAULT_PORT = 9999;
private static final int CONNECTION_TIMEOUT = 30000; // 30秒超时
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 通过socket服务执行预测
*/
public YoloConfig predictViaSocket(String modelPath, String inputPath, String outputPath, YoloConfig config) {
try {
Map<String, Object> params = new HashMap<>();
params.put("model_path", modelPath);
params.put("input_path", inputPath);
if (outputPath != null) {
params.put("output_path", outputPath);
}
// 添加其他YOLO配置参数
addYoloConfigParams(params, config);
Map<String, Object> request = new HashMap<>();
request.put("command", "predict");
request.put("params", params);
String response = sendRequest(request);
JsonNode responseJson = objectMapper.readTree(response);
if ("success".equals(responseJson.get("status").asText())) {
config.setStatus("completed");
config.setLogMessage(responseJson.get("message").asText());
if (responseJson.has("output_path")) {
config.setOutputPath(responseJson.get("output_path").asText());
}
return config;
} else {
config.setStatus("failed");
config.setErrorMessage(responseJson.get("message").asText());
return config;
}
} catch (Exception e) {
log.error("Socket预测失败", e);
config.setStatus("failed");
config.setErrorMessage("Socket预测失败: " + e.getMessage());
return config;
}
}
/**
* 通过socket服务执行训练
*/
public YoloConfig trainViaSocket(String modelPath, String dataPath, YoloConfig config) {
try {
Map<String, Object> params = new HashMap<>();
params.put("model_path", modelPath);
params.put("data_path", dataPath);
params.put("epochs", config.getEpochs());
// 添加其他训练配置参数
addYoloConfigParams(params, config);
Map<String, Object> request = new HashMap<>();
request.put("command", "train");
request.put("params", params);
String response = sendRequest(request);
JsonNode responseJson = objectMapper.readTree(response);
if ("success".equals(responseJson.get("status").asText())) {
config.setStatus("completed");
config.setLogMessage(responseJson.get("message").asText());
if (responseJson.has("results_path")) {
config.setOutputPath(responseJson.get("results_path").asText());
}
return config;
} else {
config.setStatus("failed");
config.setErrorMessage(responseJson.get("message").asText());
return config;
}
} catch (Exception e) {
log.error("Socket训练失败", e);
config.setStatus("failed");
config.setErrorMessage("Socket训练失败: " + e.getMessage());
return config;
}
}
/**
* 通过socket服务执行验证
*/
public YoloConfig validateViaSocket(String modelPath, String dataPath, YoloConfig config) {
try {
Map<String, Object> params = new HashMap<>();
params.put("model_path", modelPath);
if (dataPath != null) {
params.put("data_path", dataPath);
}
// 添加其他验证配置参数
addYoloConfigParams(params, config);
Map<String, Object> request = new HashMap<>();
request.put("command", "validate");
request.put("params", params);
String response = sendRequest(request);
JsonNode responseJson = objectMapper.readTree(response);
if ("success".equals(responseJson.get("status").asText())) {
config.setStatus("completed");
config.setLogMessage(responseJson.get("message").asText());
if (responseJson.has("metrics")) {
config.setMetrics(responseJson.get("metrics").asText());
}
return config;
} else {
config.setStatus("failed");
config.setErrorMessage(responseJson.get("message").asText());
return config;
}
} catch (Exception e) {
log.error("Socket验证失败", e);
config.setStatus("failed");
config.setErrorMessage("Socket验证失败: " + e.getMessage());
return config;
}
}
/**
* 添加YOLO配置参数到请求中
*/
private void addYoloConfigParams(Map<String, Object> params, YoloConfig config) {
if (config.getImgsz() > 0) {
params.put("imgsz", config.getImgsz());
}
if (config.getConfThresh() > 0) {
params.put("conf", config.getConfThresh());
}
if (config.getIouThresh() > 0) {
params.put("iou", config.getIouThresh());
}
if (config.getDevice() != null) {
params.put("device", config.getDevice());
}
if (config.getBatchSize() > 0) {
params.put("batch", config.getBatchSize());
}
params.put("save", config.isSave());
params.put("save_txt", config.isSaveTxt());
params.put("save_crops", config.isSaveCrops());
}
/**
* 发送请求到socket服务
*/
private String sendRequest(Map<String, Object> request) throws IOException {
try (Socket socket = new Socket(DEFAULT_HOST, DEFAULT_PORT);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
// 设置超时
socket.setSoTimeout(CONNECTION_TIMEOUT);
// 发送请求
String requestJson = objectMapper.writeValueAsString(request);
out.println(requestJson);
// 读取响应
StringBuilder response = new StringBuilder();
String line;
while ((line = in.readLine()) != null) {
response.append(line);
}
return response.toString();
}
}
/**
* 检查服务是否可用
*/
public boolean isServiceAvailable() {
try {
try (Socket socket = new Socket()) {
socket.connect(new java.net.InetSocketAddress(DEFAULT_HOST, DEFAULT_PORT), 3000);
return true;
}
} catch (IOException e) {
return false;
}
}
}

@ -1,172 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
// YoloServiceImpl.java
import cn.iocoder.yudao.module.annotation.dal.yolo.*;
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class YoloServiceImpl implements YoloService {
@Override
public TrainingResult train(YoloConfig config) {
try {
// 构建训练命令
ProcessBuilder pb = buildTrainCommand(config);
Process process = pb.start();
// 读取输出
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
// 可以在这里解析训练进度
System.out.println(line);
}
int exitCode = process.waitFor();
if (exitCode == 0) {
return new TrainingResult(true, "Training completed successfully", output.toString());
} else {
return new TrainingResult(false, "Training failed with exit code: " + exitCode, output.toString());
}
} catch (Exception e) {
return new TrainingResult(false, "Training error: " + e.getMessage(), "");
}
}
@Override
public ValidationResult validate(YoloConfig config) {
try {
ProcessBuilder pb = buildValidateCommand(config);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new ValidationResult(exitCode == 0, output.toString());
} catch (Exception e) {
return new ValidationResult(false, "Validation error: " + e.getMessage());
}
}
@Override
public PredictionResult predict(YoloConfig config, String imagePath) {
try {
ProcessBuilder pb = buildPredictCommand(config, imagePath);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new PredictionResult(exitCode == 0, output.toString(), imagePath);
} catch (Exception e) {
return new PredictionResult(false, "Prediction error: " + e.getMessage(), imagePath);
}
}
@Override
public ExportResult export(YoloConfig config, String format) {
try {
ProcessBuilder pb = buildExportCommand(config, format);
Process process = pb.start();
StringBuilder output = new StringBuilder();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
int exitCode = process.waitFor();
return new ExportResult(exitCode == 0, output.toString(), format);
} catch (Exception e) {
return new ExportResult(false, "Export error: " + e.getMessage(), format);
}
}
private ProcessBuilder buildTrainCommand(YoloConfig config) {
// 假设使用Python脚本调用YOLO
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "train",
"--model", config.getModelPath(),
"--data", config.getDatasetPath(),
"--epochs", String.valueOf(config.getEpochs()),
"--batch", String.valueOf(config.getBatchSize()),
"--lr", String.valueOf(config.getLearningRate()),
"--device", config.getDevice(),
"--project", config.getOutputPath()
);
if (config.isPretrained()) {
pb.command().add("--pretrained");
pb.command().add("True");
}
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildValidateCommand(YoloConfig config) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "val",
"--model", config.getModelPath(),
"--data", config.getDatasetPath(),
"--device", config.getDevice()
);
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildPredictCommand(YoloConfig config, String imagePath) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "predict",
"--model", config.getModelPath(),
"--source", imagePath,
"--device", config.getDevice(),
"--project", config.getOutputPath()
);
pb.redirectErrorStream(true);
return pb;
}
private ProcessBuilder buildExportCommand(YoloConfig config, String format) {
ProcessBuilder pb = new ProcessBuilder();
pb.command(
"python", "-m", "ultralytics", "export",
"--model", config.getModelPath(),
"--format", format
);
pb.redirectErrorStream(true);
return pb;
}
}

@ -1,43 +0,0 @@
package cn.iocoder.yudao.module.annotation.service.yolo;
/**
* YOLO训练配置类
*/
public class YoloTrainConfig {
private String modelPath; // 模型路径
private String datasetPath; // 数据集路径
private int epochs; // 训练轮数
private int batchSize; // 批次大小
private int imageSize; // 图像大小
private String device; // 设备
private String outputPath; // 输出路径
private String logFilePath; // 日志文件路径
private int projectId; // 项目编号
private String venvPath; // 虚拟环境路径(Scripts或bin的上一级)
public YoloTrainConfig(String modelPath, String datasetPath, int epochs, int batchSize, int imageSize,
String device, String outputPath, String logFilePath, int projectId, String venvPath) {
this.modelPath = modelPath;
this.datasetPath = datasetPath;
this.epochs = epochs;
this.batchSize = batchSize;
this.imageSize = imageSize;
this.device = device;
this.outputPath = outputPath;
this.logFilePath = logFilePath;
this.projectId = projectId;
this.venvPath = venvPath;
}
// Getters and Setters
public String getModelPath() { return modelPath; }
public String getDatasetPath() { return datasetPath; }
public int getEpochs() { return epochs; }
public int getBatchSize() { return batchSize; }
public int getImageSize() { return imageSize; }
public String getDevice() { return device; }
public String getOutputPath() { return outputPath; }
public String getLogFilePath() { return logFilePath; }
public int getProjectId() { return projectId; }
public String getVenvPath() { return venvPath; }
}

@ -124,6 +124,18 @@
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId> <!-- 短信(阿里云) -->
</dependency>
<dependency>
<groupId>com.sun.jna.examples</groupId>
<artifactId>test</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/libs/examples.jar</systemPath>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.5.7</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>

@ -26,10 +26,13 @@ import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@Component
public class MyInitializer {
// 关闭标志
private static final AtomicBoolean shutdownFlag = new AtomicBoolean(false);
@Resource
private OkHttpClient client ;
@ -45,9 +48,12 @@ public class MyInitializer {
private ExecutorService executorService = Executors.newFixedThreadPool(20);
@Scheduled(fixedRate = 30000) // 每分钟执行一次
@Scheduled(fixedRate = 30000) // 每30秒执行一次
public void checkAndAddRtspProxies() {
// 检查关闭标志
if (shutdownFlag.get()) {
return;
}
List<CameraDO> list =cameraService.list();
//添加流信息
@ -63,12 +69,27 @@ public class MyInitializer {
e.printStackTrace();
}
}
@PreDestroy
public void destroy() {
// 设置关闭标志
shutdownFlag.set(true);
// 关闭线程池
if (executorService != null && !executorService.isShutdown()) {
executorService.shutdown();
executorService.shutdownNow();
}
}
public static void setShutdown() {
shutdownFlag.set(true);
}
/**
* 关闭线程池
*/
public void shutdownExecutor() {
if (executorService != null && !executorService.isShutdown()) {
executorService.shutdownNow();
}
}

@ -0,0 +1,744 @@
package cn.iocoder.yudao.module.camera.controller.cameraControl;
import cn.iocoder.yudao.module.camera.lib.camera.hik.HCNetSDK;
import com.sun.jna.Pointer;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.FFmpegLogCallback;
import org.bytedeco.javacv.Frame;
import java.io.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
/**
* 录像回放 Demo(NetSDK 控制 + JavaCV 推流到 ZLM)
*
* <p>架构(使用 JavaCV 进程内 FFmpeg):
* <pre>
* 海康摄像头 ←→ HCNetSDK (NET_DVR_PlayBackByTime + 回调拿 H.264/H.265 帧)
* ↓ PipedOutputStream → PipedInputStream(JVM 内存内通信,不跨进程)
* FFmpegFrameGrabber (starts FFmpeg demuxer + decoder → Frame 对象)
* ↓
* FFmpegFrameRecorder (H.264 编码 → RTMP 推流)
* ↓
* ZLM (127.0.0.1:8000) → Web/WebRTC 观看
* </pre>
*
* <p>支持的控制操作:前进/后退 10 分钟(停流→时钟偏移→重新拉流)</p>
*
* @author lidaofu
* @since 2023/12/1
*/
public class RecordPlayDemo {
// ==================== 配置 ====================
/** ZLM 流媒体端口 */
private static final int ZLM_RTMP_PORT = 1935;
private static final int ZLM_HTTP_PORT = 8000;
/** 摄像头连接信息 */
private static final String CAMERA_IP = "192.168.100.69";
private static final short CAMERA_SDK_PORT = 8000;
private static final String CAMERA_USER = "admin";
private static final String CAMERA_PASSWORD = "a1234567";
// ==================== SDK 实例 ====================
private static final HCNetSDK hCNetSDK = HCNetSDK.INSTANCE;
// ==================== 状态 ====================
private static int lUserID = -1;
private static int lPlayHandle = -1;
private static final AtomicBoolean running = new AtomicBoolean(false);
/** 管道流(SDK 回调写 → grabber 读) */
private static PipedOutputStream pipedOut;
/** 单段播放完毕信号 */
private static CountDownLatch segmentDone;
/** 管道数据就绪信号(SDK 回调 unpark → grabber 线程唤醒) */
private static final AtomicBoolean pipeReady = new AtomicBoolean(false);
/** 强引用保持回调对象不被 GC 回收 */
private static HCNetSDK.FPlayDataCallBack sPlayCallback;
/** 看门狗:最后一次收到 SDK 回调数据的时间(ms) */
private static final AtomicLong lastDataTime = new AtomicLong(0);
/** 抓取+录制线程引用(用于 LockSupport.unpark 唤醒) */
private static Thread grabThread;
/** segment 循环线程 */
private static Thread playbackThread;
/** 虚拟播放时钟:当前推流的视频时间位置 */
private static volatile LocalDateTime playbackClock;
/** 是否请求 seek(跳转) */
private static volatile boolean seekRequested = false;
/** seek 偏移量(±10 分钟) */
private static volatile int seekOffsetMinutes = 0;
// ==================== 入口 ====================
public static void main(String[] args) throws Exception {
try {
// 1. 初始化 SDK + 登录
initSDK();
login(CAMERA_IP, CAMERA_SDK_PORT, CAMERA_USER, CAMERA_PASSWORD);
// 2. 启动回放 + JavaCV 推流
startPlaybackAndStream(
1, // 通道号
LocalDate.now().atStartOfDay().plusMinutes(80), // 开始时间
LocalDateTime.now() // 结束时间
);
// 3. 交互式控制
interactiveControl();
} finally {
cleanup();
}
}
// ==================== SDK 初始化 & 登录 ====================
private static void initSDK() {
if (!hCNetSDK.NET_DVR_Init()) {
throw new RuntimeException("SDK 初始化失败,错误码=" + hCNetSDK.NET_DVR_GetLastError());
}
hCNetSDK.NET_DVR_SetConnectTime(2000, 1);
hCNetSDK.NET_DVR_SetReconnect(10000, true);
System.out.println("[SDK] 初始化成功");
}
private static void login(String ip, short port, String user, String password) {
HCNetSDK.NET_DVR_USER_LOGIN_INFO loginInfo = new HCNetSDK.NET_DVR_USER_LOGIN_INFO();
System.arraycopy(ip.getBytes(), 0, loginInfo.sDeviceAddress, 0, ip.length());
loginInfo.wPort = port;
System.arraycopy(user.getBytes(), 0, loginInfo.sUserName, 0, user.length());
System.arraycopy(password.getBytes(), 0, loginInfo.sPassword, 0, password.length());
loginInfo.bUseAsynLogin = false;
loginInfo.write();
HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40();
lUserID = hCNetSDK.NET_DVR_Login_V40(loginInfo, deviceInfo);
if (lUserID == -1) {
throw new RuntimeException("登录失败,错误码=" + hCNetSDK.NET_DVR_GetLastError());
}
System.out.println("[SDK] 登录成功: " + ip);
}
// ==================== 回放 + JavaCV 推流 ====================
/** 录像片段信息 */
static class SegmentInfo {
final HCNetSDK.NET_DVR_TIME start;
final HCNetSDK.NET_DVR_TIME stop;
SegmentInfo(HCNetSDK.NET_DVR_TIME s, HCNetSDK.NET_DVR_TIME e) { this.start = s; this.stop = e; }
}
/**
* 先查询所有录像片段,再逐个回放,串成一条 RTMP 流
*/
private static void startPlaybackAndStream(int channel, LocalDateTime start, LocalDateTime end) {
String app = "live";
String stream = "record";
running.set(true);
printPlayUrls(app, stream);
String rtmpUrl = "rtmp://127.0.0.1:" + ZLM_RTMP_PORT + "/" + app + "/" + stream;
playbackClock = start;
playbackThread = new Thread(() -> {
try {
while (running.get()) {
// 从虚拟时钟位置查询录像片段
List<SegmentInfo> segments = querySegments(channel, playbackClock, end);
if (segments.isEmpty()) {
System.out.println("[回放] 没有更多录像");
break;
}
System.out.println("[查询] 共 " + segments.size() + " 个录像片段,从 " + playbackClock + " 开始");
for (int i = 0; i < segments.size(); i++) {
if (!running.get() || seekRequested) break;
SegmentInfo seg = segments.get(i);
// seek 后的第一个片段:从 playbackClock 位置开始;后续片段从头开始
LocalDateTime actualStart = (i == 0) ? playbackClock : dvrTimeToLocalDateTime(seg.start);
System.out.println("[回放] 片段 " + (i + 1) + "/" + segments.size()
+ ": " + seg.start.toStringTime() + " ~ " + seg.stop.toStringTime()
+ ",实际从 " + actualStart + " 开始");
playOneSegment(channel, seg, rtmpUrl, actualStart, i + 1, segments.size());
if (seekRequested) break;
// 片段正常结束:更新虚拟时钟(seek 时不更新,因为 seekByMinutes 已设精确位置)
playbackClock = dvrTimeToLocalDateTime(seg.stop);
}
if (seekRequested && running.get()) {
seekRequested = false;
playbackClock = playbackClock.plusMinutes(seekOffsetMinutes);
seekOffsetMinutes = 0;
System.out.println("[Seek] 跳转到 " + playbackClock);
continue; // re-query from new position
}
break; // all segments done
}
System.out.println("[回放] 流仍在线,按 q 退出");
} catch (Exception e) {
System.err.println("[回放] 异常: " + e.getMessage());
e.printStackTrace();
}
}, "segment-playback");
playbackThread.setDaemon(true);
playbackThread.start();
}
/**
* 播放一个录像片段(使用 JavaCV 进程内 FFmpeg)
* <pre>
* 流程:
* 1. 创建 PipedStream 对(SDK回调→写端,Grabber→读端)
* 2. 启动 SDK 回放(回调把原始字节写入管道)
* 3. 等待数据到达管道
* 4. 用 FFmpegFrameGrabber 从管道解封装+解码
* 5. 用 FFmpegFrameRecorder 重新编码(H.264)并推送 RTMP
* </pre>
*/
private static void playOneSegment(int channel, SegmentInfo seg, String rtmpUrl,
LocalDateTime actualStart, int segIdx, int total) {
// 1MB 管道缓冲
PipedInputStream pis;
try {
pis = new PipedInputStream(1024 * 1024);
pipedOut = new PipedOutputStream(pis);
} catch (IOException e) {
System.err.println("[管道] 创建失败: " + e.getMessage());
return;
}
segmentDone = new CountDownLatch(1);
lastDataTime.set(System.currentTimeMillis());
pipeReady.set(false);
// ★ 模仿 downloadMp4 的思路:PlayBackByTime 直接传精确时间,不做扩展也不搞 PLAYSETPOS
// 对应 GetFileByTime(start, end) 的做法,只是数据走回调而非写文件
HCNetSDK.NET_DVR_TIME dvrStart = toDvrTime(actualStart);
HCNetSDK.NET_DVR_TIME dvrStop = toDvrTime(dvrTimeToLocalDateTime(seg.stop));
dvrStart.write();
dvrStop.write();
lPlayHandle = hCNetSDK.NET_DVR_PlayBackByTime(lUserID, channel, dvrStart, dvrStop, null);
if (lPlayHandle == -1) {
System.err.println("[回放] 启动失败,err=" + hCNetSDK.NET_DVR_GetLastError());
closePipe();
return;
}
System.out.println("[回放] 句柄=" + lPlayHandle + ", 从 " + actualStart
+ " 到 " + dvrTimeToLocalDateTime(seg.stop));
// -- 注册回调:SDK 数据直接写入管道 --
sPlayCallback = new HCNetSDK.FPlayDataCallBack() {
private final AtomicInteger frameCount = new AtomicInteger(0);
private volatile boolean done = false;
@Override
public void invoke(int lPlayHandle, int dwDataType, Pointer pBuffer, int dwBufSize, int dwUser) {
try {
if (done || pipedOut == null) return;
// 系统头(type=1)是 NetSDK 私有格式,跳过(干扰 MPEG-PS 解析)
if (dwDataType == HCNetSDK.NET_DVR_SYSHEAD) {
return;
}
if (dwDataType == HCNetSDK.NET_DVR_STREAMDATA
|| dwDataType == HCNetSDK.NET_DVR_STD_VIDEODATA) {
if (!running.get()) return;
lastDataTime.set(System.currentTimeMillis());
byte[] data = pBuffer.getByteArray(0, dwBufSize);
try {
pipedOut.write(data);
int cnt = frameCount.incrementAndGet();
// 每 50 帧 flush 一次(减少系统调用开销)
if (cnt % 50 == 0) {
pipedOut.flush();
// 第一次批量数据到达后,唤醒 grabber 线程开始 probe
if (cnt == 50 && !pipeReady.getAndSet(true)) {
Thread gThread = grabThread;
if (gThread != null) {
LockSupport.unpark(gThread);
}
}
}
} catch (IOException e) {
if (!done) {
System.err.println("[回调] 管道写入失败: " + e.getMessage());
}
closePipe();
done = true;
segmentDone.countDown();
}
} else if (dwDataType == HCNetSDK.NET_DVR_PLAYBACKOVER) {
done = true;
System.out.println("[回放] 本段播放完毕,共 " + frameCount.get() + " 帧");
// 等待 300ms 让尾部帧落盘到管道
try { Thread.sleep(300); } catch (InterruptedException ignored) {}
closePipe(); // 关闭写端 → 通知读端 EOF
segmentDone.countDown();
}
} catch (Exception e) {
System.err.println("[回调] 异常! " + e.getClass().getSimpleName() + ": " + e.getMessage());
e.printStackTrace();
}
}
};
boolean cbOk = hCNetSDK.NET_DVR_SetPlayDataCallBack_V40(lPlayHandle, sPlayCallback, Pointer.NULL);
System.out.println("[回调] 注册状态=" + cbOk);
// -- ★ 先启动 grabber 线程(park 等待),再 SDK PLAYSTART ★ --
// 必须提前创建 grabThread,这样回调里 LockSupport.unpark(grabThread) 才能正确唤醒
grabThread = new Thread(() -> runGrabberRecorder(pis, rtmpUrl, segIdx), "grabber-recorder");
grabThread.setDaemon(true);
grabThread.start();
if (!hCNetSDK.NET_DVR_PlayBackControl(lPlayHandle, HCNetSDK.NET_DVR_PLAYSTART, 0, null)) {
System.err.println("[回放] 开始播放失败,err=" + hCNetSDK.NET_DVR_GetLastError());
hCNetSDK.NET_DVR_StopPlayBack(lPlayHandle);
lPlayHandle = -1;
closePipe();
grabThread = null;
return;
}
System.out.println("[回放] 数据推送中...");
// -- 等待当前段播放完成 --
waitForSegmentDone(segIdx, total);
// -- 清理(seek 时已经被 stopCurrentPlayback 清理,这里兜底) --
if (grabThread != null && grabThread.isAlive()) {
closePipe();
try { grabThread.join(5000); } catch (InterruptedException ignored) {}
if (grabThread.isAlive()) {
grabThread.interrupt();
}
}
grabThread = null;
closePipe();
if (lPlayHandle >= 0) {
hCNetSDK.NET_DVR_StopPlayBack(lPlayHandle);
lPlayHandle = -1;
}
sPlayCallback = null;
}
private static void runGrabberRecorder(PipedInputStream pis, String rtmpUrl, int segIdx) {
avutil.av_log_set_level(avutil.AV_LOG_FATAL);
FFmpegLogCallback.set();
FFmpegFrameGrabber grabber = null;
FFmpegFrameRecorder recorder = null;
try {
// 等待回调信号,超时 12s
long parkEnd = System.currentTimeMillis() + 12_000;
while (!pipeReady.get() && System.currentTimeMillis() < parkEnd) {
LockSupport.parkUntil(parkEnd);
}
pipeReady.set(false);
int avail = pis.available();
System.out.println("[Grabber] 管道就绪, available=" + avail + " bytes");
if (avail < 500_000) {
Thread.sleep(500);
avail = pis.available();
System.out.println("[Grabber] 等待后 available=" + avail + " bytes");
}
// ★ 预读 3MB 到 ByteArrayInputStream 供 FFmpeg probe 使用(支持 mark/reset),
// probe 完成后 SequenceInputStream 无缝衔接剩余管道数据
byte[] probeBuf = new byte[Math.min(avail, 3 * 1024 * 1024)];
int probeLen = 0, totalRead = 0;
long readDeadline = System.currentTimeMillis() + 5000;
while (totalRead < probeBuf.length && System.currentTimeMillis() < readDeadline) {
int n = pis.read(probeBuf, totalRead, probeBuf.length - totalRead);
if (n < 0) break;
totalRead += n;
probeLen = totalRead;
if (totalRead < probeBuf.length / 2 && pis.available() == 0) {
Thread.sleep(100);
}
}
System.out.println("[Grabber] 预读完成: " + probeLen + " bytes");
ByteArrayInputStream probeStream = new ByteArrayInputStream(probeBuf, 0, probeLen);
SequenceInputStream combinedStream = new SequenceInputStream(probeStream, pis);
// -- 1. 启动 Grabber --
grabber = new FFmpegFrameGrabber(combinedStream);
grabber.setFormat("mpeg");
grabber.setOption("stimeout", "5000000");
grabber.setOption("probesize", String.valueOf(Math.min(probeLen, 2 * 1024 * 1024)));
grabber.setOption("analyzeduration", "5000000");
grabber.start();
int width = grabber.getImageWidth();
int height = grabber.getImageHeight();
int channels = grabber.getAudioChannels();
int pixelFmt = grabber.getPixelFormat();
double fps = grabber.getFrameRate() > 0 ? grabber.getFrameRate() : 25;
int videoBitrate = grabber.getVideoBitrate() > 0 ? grabber.getVideoBitrate() : 2000000;
System.out.println("[Grabber] 启动成功: " + width + "x" + height
+ ", fps=" + fps + ", channels=" + channels
+ ", codecId=" + grabber.getVideoCodec()
+ ", codecName=" + grabber.getVideoCodecName()
+ ", pixelFmt=" + pixelFmt + "(" + getPixelFmtName(pixelFmt) + ")"
+ ", bitrate=" + videoBitrate);
// -- 2. 启动 Recorder --
recorder = new FFmpegFrameRecorder(rtmpUrl, width, height, channels);
recorder.setFormat("flv");
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setVideoBitrate(videoBitrate);
recorder.setFrameRate(fps);
recorder.setGopSize(Math.max(25, (int) fps));
recorder.setVideoOption("preset", "ultrafast");
recorder.setVideoOption("tune", "zerolatency");
recorder.setVideoOption("crf", "23");
recorder.setMaxDelay(0);
if (channels > 0) {
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
recorder.setSampleRate(grabber.getSampleRate() > 0 ? grabber.getSampleRate() : 8000);
recorder.setAudioChannels(channels);
recorder.setAudioBitrate(grabber.getAudioBitrate() > 0 ? grabber.getAudioBitrate() : 64000);
}
recorder.start();
System.out.println("[Recorder] 启动成功, 推流到 " + rtmpUrl);
// -- 3. 抓帧 → 编码 → 推送(显式 PTS + 帧计数节流) --
long frameNum = 0;
long ptsPerFrame = 1_000_000L / (long) fps; // 每帧 PTS 增量(微秒)
long startRealMs = System.currentTimeMillis(); // 开始推流的现实时间
Frame frame;
while (running.get() && (frame = grabber.grab()) != null) {
if (frame.image != null) {
// ★ 显式设置 PTS,避免编码器内部 PTS 自增与 sleep 冲突
recorder.setTimestamp(frameNum * ptsPerFrame);
recorder.record(frame);
frameNum++;
// ★ 速度控制:基于帧数计算目标时刻,sleep 对齐
long targetElapsedMs = frameNum * 1000L / (long) fps;
long realElapsedMs = System.currentTimeMillis() - startRealMs;
long sleepMs = targetElapsedMs - realElapsedMs;
if (sleepMs > 2) {
try { Thread.sleep(sleepMs); } catch (InterruptedException e) { break; }
}
if (frameNum % 100 == 1) {
System.out.println("[Recorder] 已推流 " + frameNum + " 帧, 预期 fps≈"
+ String.format("%.1f", frameNum * 1000.0 / Math.max(System.currentTimeMillis() - startRealMs, 1)));
}
}
}
System.out.println("[Recorder] 片段 " + segIdx + " 推流完毕,共 " + frameNum + " 帧");
} catch (Exception e) {
System.err.println("[Grabber/Recorder] 异常: " + e.getClass().getSimpleName() + ": " + e.getMessage());
e.printStackTrace();
} finally {
closeQuietly(recorder);
closeQuietly(grabber);
System.out.println("[Grabber/Recorder] 已释放");
}
}
private static String getPixelFmtName(int fmt) {
if (fmt == 0) return "YUV420P";
if (fmt == 1) return "YUYV422";
if (fmt == 2) return "RGB24";
if (fmt == 3) return "BGR24";
if (fmt == 4) return "YUV422P";
if (fmt == 5) return "YUV444P";
if (fmt == 12) return "YUVJ420P";
if (fmt == 23) return "NV12";
if (fmt == 26) return "NV21";
return "fmt#" + fmt;
}
private static void closeQuietly(AutoCloseable ac) {
if (ac != null) {
try { ac.close(); } catch (Exception ignored) {}
}
}
private static void closeQuietly(FFmpegFrameGrabber g) {
if (g != null) {
try { g.close(); } catch (Exception ignored) {}
}
}
private static void closeQuietly(FFmpegFrameRecorder r) {
if (r != null) {
try { r.close(); } catch (Exception ignored) {}
}
}
private static void closePipe() {
if (pipedOut != null) {
try { pipedOut.close(); } catch (IOException ignored) {}
pipedOut = null;
}
}
/**
* 带看门狗超时的等待
* @return true=正常结束(PLAYBACKOVER), false=看门狗超时或基础设施挂了
*/
private static boolean waitForSegmentDone(int segIdx, int total) {
while (true) {
try {
if (segmentDone.await(2, TimeUnit.SECONDS)) {
return true;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
// seek 请求 → 立刻退出等待
if (seekRequested) {
System.out.println("[回放] Seek 请求,中断当前段");
return false;
}
// grabber 线程挂了
boolean grabberDead = grabThread == null || !grabThread.isAlive();
if (grabberDead) {
return false;
}
// 看门狗:15s 无数据
long idleMs = System.currentTimeMillis() - lastDataTime.get();
if (idleMs > 15_000) {
System.err.println("[回放] 看门狗超时! 已 " + (idleMs / 1000) + "s 无回调数据");
return false;
}
}
}
// ==================== 查询录像片段 ====================
private static List<SegmentInfo> querySegments(int channel, LocalDateTime start, LocalDateTime end) {
System.out.println("[查询] 开始查找录像文件(V30): channel=" + channel
+ ", " + start + " ~ " + end);
List<SegmentInfo> list = new ArrayList<>();
HCNetSDK.NET_DVR_FILECOND cond = new HCNetSDK.NET_DVR_FILECOND();
cond.lChannel = channel;
cond.dwFileType = 0xff;
cond.dwIsLocked = 0xff;
cond.struStartTime = toDvrTime(start);
cond.struStopTime = toDvrTime(end);
cond.write();
int findHandle = hCNetSDK.NET_DVR_FindFile_V30(lUserID, cond);
if (findHandle < 0) {
System.err.println("[查询] V30 查找文件失败,err=" + hCNetSDK.NET_DVR_GetLastError());
return list;
}
HCNetSDK.NET_DVR_FINDDATA_V30 fileData = new HCNetSDK.NET_DVR_FINDDATA_V30();
long totalSize = 0;
while (true) {
int ret = hCNetSDK.NET_DVR_FindNextFile_V30(findHandle, fileData);
if (ret == HCNetSDK.NET_DVR_FILE_SUCCESS) {
fileData.read();
SegmentInfo seg = new SegmentInfo(copyTime(fileData.struStartTime), copyTime(fileData.struStopTime));
list.add(seg);
totalSize += fileData.dwFileSize;
String fname = new String(fileData.sFileName).trim().split("\0")[0];
System.out.println("[查询] " + fname
+ " " + fileData.struStartTime.toStringTime() + " ~ " + fileData.struStopTime.toStringTime()
+ ", size=" + fileData.dwFileSize);
} else if (ret == HCNetSDK.NET_DVR_ISFINDING) {
continue;
} else {
if (ret == HCNetSDK.NET_DVR_NOMOREFILE) {
System.out.println("[查询] 查找完毕,共 " + list.size() + " 个文件"
+ ",总大小=" + (totalSize / 1024 / 1024) + "MB");
} else {
System.err.println("[查询] 查找异常,ret=" + ret);
}
break;
}
}
hCNetSDK.NET_DVR_FindClose_V30(findHandle);
return list;
}
// ==================== Seek 控制 ====================
/**
* 向前/向后跳转 N 分钟
* <p>
* 原理简图:
* <pre>
* playbackClock ──── 每段结束后自动更新为 seg.stop
* │
* seekByMinutes(±10)
* │ 1. 停掉当前 SDK PlayBackByTime + 管道
* │ 2. playbackClock += ±10min
* │ 3. seekRequested = true
* │
* segment 循环检测到 seekRequested →
* │ 用新的 playbackClock 重新 querySegments()
* │ → PlayBackByTime(playbackClock, seg.stop) ← 一步到位,精确时间
* ↓ 从头拉流
* </pre>
* 为什么不做 PLAYGETTIME?
* PlayBackByTime(actualStart, seg.stop) 已经是精确时间定位,SDK 自己按时间
* 戳对齐数据流。seek 后 playbackClock 偏移 ±10min,下一轮循环直接把新时间
* 传给 PlayBackByTime,效果完全等同于 SDK 内部的 PLAYSETPOS 但更可靠。
*/
public static void seekBackward() { seekByMinutes(-10); }
public static void seekForward() { seekByMinutes(10); }
private static void seekByMinutes(int minutes) {
if (!running.get()) {
System.out.println("[Seek] 未在播放中");
return;
}
// 停当前流
stopCurrentPlayback();
// 偏移时钟(playbackClock 在每段结束后已精确同步到 seg.stop)
playbackClock = playbackClock.plusMinutes(minutes);
seekOffsetMinutes = minutes;
seekRequested = true;
System.out.println("[Seek] " + (minutes > 0 ? "+" : "") + minutes
+ " 分钟 → 跳转到 " + playbackClock + ",重新拉流");
}
/** 立即停掉当前 SDK 回放 + 管道 */
private static void stopCurrentPlayback() {
closePipe();
if (lPlayHandle >= 0) {
hCNetSDK.NET_DVR_StopPlayBack(lPlayHandle);
lPlayHandle = -1;
}
if (segmentDone != null) {
segmentDone.countDown();
}
}
// ==================== 交互式控制台 ====================
private static void interactiveControl() {
System.out.println("\n==================== 控制指令 ====================");
System.out.println(" [左方向键] 或 b / - 后退 10 分钟(停流→重新拉流)");
System.out.println(" [右方向键] 或 f / + 前进 10 分钟(停流→重新拉流)");
System.out.println(" q / stop 退出");
System.out.println("==================================================\n");
Scanner scanner = new Scanner(System.in);
while (running.get()) {
if (!scanner.hasNextLine()) break;
String cmd = scanner.nextLine().trim().toLowerCase();
switch (cmd) {
case "b": case "-": seekBackward(); break;
case "f": case "+": seekForward(); break;
case "q": case "stop":
running.set(false);
stopCurrentPlayback();
return;
default:
if (!cmd.isEmpty()) System.out.println("未知指令: " + cmd + "(b=后退 f=前进 q=退出)");
}
if (running.get()) System.out.print("> ");
}
}
// ==================== 清理 ====================
private static void cleanup() {
running.set(false);
closePipe();
if (playbackThread != null && playbackThread.isAlive()) {
playbackThread.interrupt();
try { playbackThread.join(2000); } catch (InterruptedException ignored) {}
}
if (grabThread != null && grabThread.isAlive()) {
grabThread.interrupt();
try { grabThread.join(3000); } catch (InterruptedException ignored) {}
}
if (lPlayHandle >= 0) {
hCNetSDK.NET_DVR_StopPlayBack(lPlayHandle);
lPlayHandle = -1;
}
if (lUserID >= 0) {
hCNetSDK.NET_DVR_Logout(lUserID);
lUserID = -1;
}
hCNetSDK.NET_DVR_Cleanup();
System.out.println("[SDK] 已释放");
}
// ==================== 工具 ====================
private static HCNetSDK.NET_DVR_TIME toDvrTime(LocalDateTime dt) {
HCNetSDK.NET_DVR_TIME t = new HCNetSDK.NET_DVR_TIME();
t.dwYear = dt.getYear();
t.dwMonth = dt.getMonthValue();
t.dwDay = dt.getDayOfMonth();
t.dwHour = dt.getHour();
t.dwMinute = dt.getMinute();
t.dwSecond = dt.getSecond();
return t;
}
private static HCNetSDK.NET_DVR_TIME copyTime(HCNetSDK.NET_DVR_TIME src) {
HCNetSDK.NET_DVR_TIME dst = new HCNetSDK.NET_DVR_TIME();
dst.dwYear = src.dwYear;
dst.dwMonth = src.dwMonth;
dst.dwDay = src.dwDay;
dst.dwHour = src.dwHour;
dst.dwMinute = src.dwMinute;
dst.dwSecond = src.dwSecond;
return dst;
}
private static LocalDateTime dvrTimeToLocalDateTime(HCNetSDK.NET_DVR_TIME t) {
return LocalDateTime.of(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond);
}
private static void printPlayUrls(String app, String stream) {
String wsHost = "ws://127.0.0.1:" + ZLM_HTTP_PORT;
String httpHost = "http://127.0.0.1:" + ZLM_HTTP_PORT;
String rtmpHost = "rtmp://127.0.0.1:" + ZLM_RTMP_PORT;
String base = "/" + app + "/" + stream;
System.out.println("\n==================== 观看地址 ====================");
System.out.println("WebSocket-FLV: " + wsHost + base + ".live.flv");
System.out.println("HTTP-FLV: " + httpHost + base + ".live.flv");
System.out.println("WebRTC: " + httpHost + "/index/api/webrtc?app=" + app + "&stream=" + stream + "&type=play");
System.out.println("RTMP: " + rtmpHost + base);
System.out.println("==================================================\n");
}
}

@ -0,0 +1,155 @@
//package cn.iocoder.yudao.module.camera.dal.zlm;
//
//import com.aizuda.zlm4j.callback.IMKProxyPlayerCallBack;
//import com.aizuda.zlm4j.core.ZLMApi;
//import com.aizuda.zlm4j.structure.MK_EVENTS;
//import com.aizuda.zlm4j.structure.MK_INI;
//import com.aizuda.zlm4j.structure.MK_PROXY_PLAYER;
//import com.sun.jna.Native;
//import com.sun.jna.Pointer;
//
//public class Zlm4JService {
// //动态链接库放在/resource/win32-x86-64&/resource/linux-x86-64下JNA会自动查找目录
//// public static ZLMApi ZLM_API = Native.load("D:\\git\\test\\ZLMediaKit\\release\\windows\\Debug\\zlmMediakit\\mk_api.dll", ZLMApi.class);
// //Windows环境测试
// public static ZLMApi ZLM_API = Native.load("D:\\git\\lpNewgit\\libs\\zlm\\mk_api.dll", ZLMApi.class);
// //Linux环境测试
// //public static ZLMApi ZLM_API = Native.load("/opt/media/libmk_api.so", ZLMApi.class);
//
// public static void main(String[] args) throws InterruptedException {
// //初始化sdk配置
// ZLM_API.mk_env_init2(1, 1, 1, null, 0, 0, null, 0, null, null);
// //初始化环境配置 SDK参数配置详见ZLM4J参数配置
// MK_INI mkIni = ZLM_API.mk_ini_default();
// //配置参数 全部配置参数及说明见(resources/conf.ini)
// ZLM_API.mk_ini_set_option(mkIni, "general.mediaServerId", "JMediaServer");
// ZLM_API.mk_ini_set_option(mkIni, "http.notFound", "<h1 style=\"text-align:center;\">Media Server V1.0 By LiDaoFu</h1>");
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.auto_close", 0);
// ZLM_API.mk_ini_set_option_int(mkIni, "general.streamNoneReaderDelayMS", 30000);
// ZLM_API.mk_ini_set_option_int(mkIni, "general.maxStreamWaitMS", 30000);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_ts", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_hls", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_fmp4", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_rtsp", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_rtmp", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_mp4", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_hls_fmp4", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.enable_audio", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.mp4_as_player", 1);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.mp4_max_second", 3600);
// ZLM_API.mk_ini_set_option(mkIni, "http.rootPath", "D:/www");
// ZLM_API.mk_ini_set_option(mkIni, "protocol.mp4_save_path", "D:/www");
// ZLM_API.mk_ini_set_option(mkIni, "protocol.hls_save_path", "D:/www");
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.hls_demand", 0);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.rtsp_demand", 0);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.rtmp_demand", 0);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.ts_demand", 0);
// ZLM_API.mk_ini_set_option_int(mkIni, "protocol.fmp4_demand", 0);
// //全局回调 全部回调见MK_EVENTS内所有的回调属性,如果配置了推流回调、播放回调请务必执行invoker_do,否则无法推流、回放
// MK_EVENTS mkEvents = new MK_EVENTS();
// //流状态改变回调
// mkEvents.on_mk_media_changed = (regist, sender) -> {
// System.out.println("app:" + ZLM_API.mk_media_source_get_app(sender));
// System.out.println("stream:" + ZLM_API.mk_media_source_get_stream(sender));
// System.out.println("schema:" + ZLM_API.mk_media_source_get_schema(sender));
// System.out.println("这里是流改变回调通知:" + regist);
// };
// //无人观看回调
// mkEvents.on_mk_media_no_reader = sender -> {
// System.out.println("这里是无人观看回调通知");
// ZLM_API.mk_media_source_close(sender, 1);
// };
// //推流回调 可控制鉴权、录制、转协议控制等
// mkEvents.on_mk_media_publish = (url_info, invoker, sender) -> {
// //这里拿到访问路径后(例如rtmp://xxxx/xxx/xxx?token=xxxx其中?后面就是拿到的参数)的参数
// // err_msg返回 空字符串表示鉴权成功 否则鉴权失败提示
// //String param = ZLM_API.mk_media_info_get_params(url_info);
// MK_INI option = ZLM_API.mk_ini_create();
// ZLM_API.mk_ini_set_option_int(option, "enable_mp4", 1);
// ZLM_API.mk_ini_set_option_int(option, "enable_audio", 1);
// ZLM_API.mk_ini_set_option_int(option, "enable_fmp4",1);
// ZLM_API.mk_ini_set_option_int(option, "enable_ts", 1);
// ZLM_API.mk_ini_set_option_int(option, "enable_hls",1);
// ZLM_API.mk_ini_set_option_int(option, "enable_rtsp", 1);
// ZLM_API.mk_ini_set_option_int(option, "enable_rtmp", 1);
// ZLM_API.mk_ini_set_option_int(option, "auto_close", 0);
// ZLM_API.mk_ini_set_option(option, "stream_replace", "test1");
// //流名称替换
// //ZLM_API.mk_ini_set_option(option, "stream_replace", "test1");
// ZLM_API.mk_publish_auth_invoker_do2(invoker, "", option);
// ZLM_API.mk_ini_release(option);
// //ZLM_API.mk_publish_auth_invoker_do(invoker, "", 0, 0);
// };
// //添加全局回调
// ZLM_API.mk_events_listen(mkEvents);
// //创建http服务器 0:失败,非0:端口号
// short http_server_port = ZLM_API.mk_http_server_start((short) 7788, 0);
// //创建rtsp服务器 0:失败,非0:端口号
// short rtsp_server_port = ZLM_API.mk_rtsp_server_start((short) 554, 0);
// //创建rtmp服务器 0:失败,非0:端口号
// short rtmp_server_port = ZLM_API.mk_rtmp_server_start((short) 1935, 0);
// //创建RTC服务器 0:失败,非0:端口号
// short rtc_server_port = ZLM_API.mk_rtc_server_start((short) 8000);
// /*****************************下面为推流及播放********************************/
// // 推流:利用obs、ffmpeg 进行推流 RTMP推流:rtmp://127.0.0.1:rtmp_port/流APP/流名称 RTSP推流:rtsp://127.0.0.1:rtsp_port/流APP/流名称
// // 下面是各协议拉流播放的访问格式
// // HTTP-FLV拉流:http://ip:http_port/流APP/流名称.live.flv
// // WS-FLV拉流:ws://ip:http_port/流APP/流名称.live.flv
// // HLS拉流:http://ip:http_port/流APP/流名称/hls.m3u8
// // RTMP拉流:rtmp://ip:rtmp_port/流APP/流名称
// // RTSP拉流:rtsp://ip:rtsp_port/流APP/流名称
// /*****************************下面为流代理演示********************************/
// //创建拉流代理
// MK_INI option = ZLM_API.mk_ini_create();
// ZLM_API.mk_ini_set_option_int(option, "enable_mp4", 0);
// ZLM_API.mk_ini_set_option_int(option, "enable_audio", 0);
// ZLM_API.mk_ini_set_option_int(option, "enable_fmp4", 0);
// ZLM_API.mk_ini_set_option_int(option, "enable_ts", 0);
// ZLM_API.mk_ini_set_option_int(option, "enable_hls", 0);
// ZLM_API.mk_ini_set_option_int(option, "enable_rtsp", 1);
// ZLM_API.mk_ini_set_option_int(option, "enable_rtmp", 1);
// ZLM_API.mk_ini_set_option_int(option, "mp4_max_second", 3600);
// //ZLM_API.mk_ini_set_option(option,"mp4_save_path","D:/record");
// //ZLM_API.mk_ini_set_option(option,"hls_save_path","D:/record");
// ZLM_API.mk_ini_set_option_int(option, "add_mute_audio", 0);
// ZLM_API.mk_ini_set_option_int(option, "auto_close", 1);
// MK_PROXY_PLAYER mk_proxy = ZLM_API.mk_proxy_player_create4("__defaultVhost__", "live", "test",option,2);
// //设置代理参数 rtp_type rtsp播放方式:RTP_TCP = 0, RTP_UDP = 1, RTP_MULTICAST = 2
// ZLM_API.mk_proxy_player_set_option(mk_proxy, "rtp_type", "0");
// //设置代理参数 protocol_timeout_ms 协议超时时间 毫秒
// ZLM_API.mk_proxy_player_set_option(mk_proxy, "protocol_timeout_ms", "2000");
// //开始播放
// ZLM_API.mk_proxy_player_play(mk_proxy, "rtsp://admin:hk123456@192.168.1.64/h264/ch1/sub/av_stream");
// //释放资源
// ZLM_API.mk_ini_release(option);
// //第一次代理结果获取
// IMKProxyPlayerCallBack imkProxyPlayerCallBack = new IMKProxyPlayerCallBack() {
// @Override
// public void invoke(Pointer pUser, int err, String what, int sys_err) {
// if (err == 0){
// System.out.println("代理播放成功");
// }else{
// System.out.println("代理播放失败:"+what);
// }
// }
// };
// ZLM_API.mk_proxy_player_set_on_play_result(mk_proxy,imkProxyPlayerCallBack , mk_proxy.getPointer(),null);
// //回调关闭事件
// IMKProxyPlayerCallBack imkProxyPlayCloseCallBack = new IMKProxyPlayerCallBack() {
// @Override
// public void invoke(Pointer pUser, int err, String what, int sys_err) {
// System.out.println("拉流代理关闭");
// //这里Pointer是ZLM维护的不需要我们释放 遵循谁申请谁释放原则
// ZLM_API.mk_proxy_player_release(new MK_PROXY_PLAYER(pUser));
// }
// };
// //添加代理关闭回调 并把代理客户端传过去释放
// ZLM_API.mk_proxy_player_set_on_close(mk_proxy, imkProxyPlayCloseCallBack, mk_proxy.getPointer());
// /*****************************end********************************/
//
// //阻塞60s
// Thread.sleep(60000L);
// //停止所有服务器
// ZLM_API.mk_stop_all_server();
// }
//}

@ -14,11 +14,14 @@ import org.springframework.context.annotation.Configuration;
public class LogisticsWebConfiguration {
/**
* infra 模块的 API 分组
* camera 模块的 API 分组
*/
@Bean
public GroupedOpenApi LogisticsGroupedOpenApi() {
return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("camera");
return GroupedOpenApi.builder()
.group("camera")
.pathsToMatch("/admin-api/camera/**", "/app-api/camera/**", "/index/api/**")
.build();
}
}

@ -57,4 +57,32 @@ public class CameraConnMap {
}
return null;
}
/**
* 获取所有海康摄像头连接
*/
public static Map<Integer, Integer> getAllConnIds() {
return conn;
}
/**
* 清空所有海康连接
*/
public static void clearAll() {
conn.clear();
}
/**
* 获取所有大华摄像头连接
*/
public static Map<Integer, NetSDKLib.LLong> getAllConnIdsDh() {
return connDh;
}
/**
* 清空所有大华连接
*/
public static void clearAllDh() {
connDh.clear();
}
}

@ -128,6 +128,26 @@ public interface CameraModule {
*/
void toPtz(Integer ptzId, Integer cameraId);
/**
* 录像回放控制(推流到 ZLM)
* <p>
* 核心逻辑与 RecordPlayDemo 一致:
* <pre>
* PlayBackByTime(exactStart, exactStop) ← 模仿 downloadMp4 的精确时间传参
* ↓ 回调拿到 H.264/H.265 裸流
* PipedStream → FFmpegFrameGrabber → FFmpegFrameRecorder
* ↓
* RTMP 推流到 ZLM: rtmp://127.0.0.1/live/record_camera{cameraId}
* </pre>
*
* @param cameraId 摄像头ID(已经登录的)
* @param command 控制命令: "play" | "pause" | "resume" | "stop"
* | "fastForward" | "slowPlay" | "seekForward" | "seekBackward"
* @param startTime 开始时间(仅 play 时需要,seek 命令用当前内部时钟偏移)
* @param endTime 结束时间(仅 play 时需要)
* @param streamType 码流类型: 0-主码流, 1-子码流
* @return true=操作成功, false=失败
*/
boolean playbackControl(Integer cameraId, String command, LocalDateTime startTime, LocalDateTime endTime, int streamType);
}

@ -45,6 +45,13 @@ public class TaskDelayExecutor {
exec.execute(new Consumer(cameraModFactory, cameraService));
}
/**
* 关闭线程池
*/
public static void shutdown() {
exec.shutdownNow();
}
private static class Consumer implements Runnable {
private CameraModFactory cameraModFactory;
private CameraService cameraService;
@ -56,7 +63,7 @@ public class TaskDelayExecutor {
@Override
public void run() {
while (true) {
while (!Thread.currentThread().isInterrupted()) {
try {
CameraDelayTask cameraDelayTask = queue.take();
if (cameraDelayTask != null) {
@ -73,7 +80,8 @@ public class TaskDelayExecutor {
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
break;
}
}
}

@ -630,6 +630,38 @@ public class DhControlModuleImpl implements CameraModule {
thread.start();
}
// ==================== 录像回放推流(大华 SDK) ====================
// TODO: 大华 SDK 的回放接口需要通过 CLIENT_FindFile + CLIENT_PlayBackByRecordFile 等实现
// 当前返回 false,后续按需补充
@Override
public boolean playbackControl(Integer cameraId, String command,
LocalDateTime startTime, LocalDateTime endTime, int streamType) {
log.warn("[大华回放] 暂未实现,cameraId={}, command={}", cameraId, command);
return false;
}
/**
* 清理大华 SDK 资源
* 调用 CLIENT_Cleanup() 会停止 SDK 内部的 native 线程
*/
public static void cleanup() {
try {
// 先注销所有连接
CameraConnMap.getAllConnIdsDh().forEach((cameraId, loginHandle) -> {
try {
netSdk.CLIENT_Logout(loginHandle);
log.info("【大华SDK】注销摄像头: {}", cameraId);
} catch (Exception e) {
log.warn("【大华SDK】注销摄像头异常: {}", cameraId, e);
}
});
CameraConnMap.clearAllDh();
// 清理 SDK
netSdk.CLIENT_Cleanup();
log.info("【大华SDK】清理完成");
} catch (Exception e) {
log.error("【大华SDK】清理异常", e);
}
}
}

@ -14,7 +14,7 @@ import java.util.List;
public class HCNetTools {
private final Logger logger = LoggerFactory.getLogger(getClass());
static HCNetSDK hCNetSDK = HCNetSDK.INSTANCE;
public static HCNetSDK hCNetSDK = HCNetSDK.INSTANCE;
//海康播放库(SDK里的PlayCtrl不是此处的PlayCtrl)
//static PlayCtrl playControl = PlayCtrl.INSTANCE;

@ -710,4 +710,161 @@ public class HikControlModuleImpl implements CameraModule {
return true;
}
// ==================== 录像回放推流 ====================
/** 每个摄像头一个回放句柄(未交给 RecordPlaybackService 管理前,先简单 map) */
private final java.util.Map<Integer, Integer> playbackHandleMap = new java.util.concurrent.ConcurrentHashMap<>();
@Override
public boolean playbackControl(Integer cameraId, String command,
LocalDateTime startTime, LocalDateTime endTime, int streamType) {
Integer lUserId = CameraConnMap.getConnId(cameraId);
if (lUserId == null) {
log.error("[回放] cameraId={} 未登录", cameraId);
return false;
}
Integer handle = playbackHandleMap.get(cameraId);
// ★ 模仿 RecordPlayDemo 的核心逻辑:PlayBackByTime 直接传精确时间,一步到位
// 等同于 downloadMp4 中 GetFileByTime(start, end) 的时间精度
switch (command) {
case "play": {
// 先停掉旧回放
if (handle != null && handle >= 0) {
hcNetsdk.NET_DVR_StopPlayBack(handle);
}
HCNetSDK.NET_DVR_TIME dvrStart = new HCNetSDK.NET_DVR_TIME();
dvrStart.setTime(startTime.getYear(), startTime.getMonthValue(),
startTime.getDayOfMonth(), startTime.getHour(),
startTime.getMinute(), startTime.getSecond());
HCNetSDK.NET_DVR_TIME dvrStop = new HCNetSDK.NET_DVR_TIME();
dvrStop.setTime(endTime.getYear(), endTime.getMonthValue(),
endTime.getDayOfMonth(), endTime.getHour(),
endTime.getMinute(), endTime.getSecond());
dvrStart.write();
dvrStop.write();
int newHandle = hcNetsdk.NET_DVR_PlayBackByTime(lUserId, streamType, dvrStart, dvrStop, null);
if (newHandle == -1) {
log.error("[回放] PlayBackByTime 失败 cameraId={}, err={}",
cameraId, hcNetsdk.NET_DVR_GetLastError());
return false;
}
playbackHandleMap.put(cameraId, newHandle);
// 注册数据回调 → 管道写入 → FFmpeg → RTMP 推流
// TODO: 委托 RecordPlaybackService 完成 PipedStream + FFmpegFrameGrabber/Recorder 链路
// 目前先注册空回调,仅验证时间控制链路
HCNetSDK.FPlayDataCallBack cb = (h, type, buf, size, user) -> {
// TODO: 数据回调 → pipedOut.write(buf) → Grabber → Recorder → RTMP
};
hcNetsdk.NET_DVR_SetPlayDataCallBack_V40(newHandle, cb, Pointer.NULL);
hcNetsdk.NET_DVR_PlayBackControl(newHandle, HCNetSDK.NET_DVR_PLAYSTART, 0, null);
log.info("[回放] cameraId={} 开始播放 {} ~ {}", cameraId, startTime, endTime);
return true;
}
case "pause": {
if (handle == null) return false;
boolean ok = hcNetsdk.NET_DVR_PlayBackControl(handle, HCNetSDK.NET_DVR_PLAYPAUSE, 0, null);
log.info("[回放] cameraId={} 暂停: {}", cameraId, ok);
return ok;
}
case "resume": {
if (handle == null) return false;
boolean ok = hcNetsdk.NET_DVR_PlayBackControl(handle, HCNetSDK.NET_DVR_PLAYRESTART, 0, null);
log.info("[回放] cameraId={} 恢复: {}", cameraId, ok);
return ok;
}
case "fastForward": {
if (handle == null) return false;
boolean ok = hcNetsdk.NET_DVR_PlayBackControl(handle, HCNetSDK.NET_DVR_PLAYFAST, 0, null);
log.info("[回放] cameraId={} 快进: {}", cameraId, ok);
return ok;
}
case "slowPlay": {
if (handle == null) return false;
boolean ok = hcNetsdk.NET_DVR_PlayBackControl(handle, HCNetSDK.NET_DVR_PLAYSLOW, 0, null);
log.info("[回放] cameraId={} 慢放: {}", cameraId, ok);
return ok;
}
case "normal": {
if (handle == null) return false;
boolean ok = hcNetsdk.NET_DVR_PlayBackControl(handle, HCNetSDK.NET_DVR_PLAYNORMAL, 0, null);
log.info("[回放] cameraId={} 正常速度: {}", cameraId, ok);
return ok;
}
case "stop": {
if (handle == null) return false;
hcNetsdk.NET_DVR_StopPlayBack(handle);
playbackHandleMap.remove(cameraId);
log.info("[回放] cameraId={} 停止", cameraId);
return true;
}
case "seekForward":
case "seekBackward": {
// ★ 和 RecordPlayDemo 一致:seekClose.mp4 暂停当前流 + 偏移时钟 + 重新拉流
// 因为 PlayBackByTime 已经精确到传入的开始时间,所以这里用
// 停流→PlayBackByTime(新时间, 原endTime) 的方式实现跳转
if (handle == null) return false;
int offset = "seekForward".equals(command) ? 10 : -10;
LocalDateTime newStart = startTime != null ? startTime.plusMinutes(offset) : endTime.minusMinutes(10);
// 停旧
hcNetsdk.NET_DVR_StopPlayBack(handle);
// 重新拉流
HCNetSDK.NET_DVR_TIME dvrStart = new HCNetSDK.NET_DVR_TIME();
dvrStart.setTime(newStart.getYear(), newStart.getMonthValue(),
newStart.getDayOfMonth(), newStart.getHour(),
newStart.getMinute(), newStart.getSecond());
HCNetSDK.NET_DVR_TIME dvrStop = new HCNetSDK.NET_DVR_TIME();
dvrStop.setTime(endTime.getYear(), endTime.getMonthValue(),
endTime.getDayOfMonth(), endTime.getHour(),
endTime.getMinute(), endTime.getSecond());
dvrStart.write();
dvrStop.write();
int newHandle = hcNetsdk.NET_DVR_PlayBackByTime(lUserId, streamType, dvrStart, dvrStop, null);
if (newHandle == -1) {
log.error("[回放] seek 后 PlayBackByTime 失败 cameraId={}, err={}",
cameraId, hcNetsdk.NET_DVR_GetLastError());
playbackHandleMap.remove(cameraId);
return false;
}
playbackHandleMap.put(cameraId, newHandle);
hcNetsdk.NET_DVR_PlayBackControl(newHandle, HCNetSDK.NET_DVR_PLAYSTART, 0, null);
log.info("[回放] cameraId={} seek{} → {}", cameraId, offset > 0 ? "+" + offset : offset, newStart);
return true;
}
default:
log.warn("[回放] 未知命令: {}", command);
return false;
}
}
/**
* 清理海康 SDK 资源
* 调用 NET_DVR_Cleanup() 会停止 SDK 内部的 native 线程
*/
public static void cleanup() {
try {
if (hasInit) {
// 先注销所有连接
CameraConnMap.getAllConnIds().forEach((cameraId, userId) -> {
try {
hcNetsdk.NET_DVR_Logout_V30(userId);
log.info("【海康SDK】注销摄像头: {}", cameraId);
} catch (Exception e) {
log.warn("【海康SDK】注销摄像头异常: {}", cameraId, e);
}
});
CameraConnMap.clearAll();
// 清理 SDK
hcNetsdk.NET_DVR_Cleanup();
hasInit = false;
log.info("【海康SDK】清理完成");
}
} catch (Exception e) {
log.error("【海康SDK】清理异常", e);
}
}
}

@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.camera.util;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 异步任务执行工具类
*/
@Slf4j
public class AsyncTaskUtil {
private static final ExecutorService ASYNC_EXECUTOR = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2,
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("async-task-" + count.getAndIncrement());
thread.setDaemon(true);
return thread;
}
}
);
/**
* 延迟任务执行器
*/
private static final ScheduledExecutorService SCHEDULED_EXECUTOR = Executors.newScheduledThreadPool(
Runtime.getRuntime().availableProcessors(),
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("scheduled-task-" + count.getAndIncrement());
thread.setDaemon(true);
return thread;
}
}
);
/**
* 通用异步执行
*/
public static void execute(Runnable task) {
ASYNC_EXECUTOR.execute(() -> {
try {
task.run();
} catch (Exception e) {
log.error("异步任务执行异常", e);
}
});
}
/**
* 关闭线程池
*/
public static void shutdown() {
ASYNC_EXECUTOR.shutdownNow();
SCHEDULED_EXECUTOR.shutdownNow();
}
/**
* 延迟执行(指定时间后执行一次)
* @param task 要执行的任务
* @param delay 延迟时间
* @param unit 时间单位
*/
public static void schedule(Runnable task, long delay, TimeUnit unit) {
SCHEDULED_EXECUTOR.schedule(() -> {
try {
task.run();
} catch (Exception e) {
log.error("延迟任务执行异常", e);
}
}, delay, unit);
}
/**
* 延迟3分钟后执行
*/
public static void schedule3Minutes(Runnable task) {
schedule(task, 3, TimeUnit.MINUTES);
}
}

@ -2,9 +2,14 @@ package cn.iocoder.yudao.module.camera.config;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
@ -26,4 +31,19 @@ public class OkHttpClientConfig {
// })
.build();
}
/**
* 创建 RestTemplate 实例
*
* @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder}
*/
@Bean
@ConditionalOnMissingBean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(15000)) // 连接超时 15秒
.setReadTimeout(Duration.ofMillis(20000)) // 读取超时 20秒
.build();
}
}

@ -11,8 +11,11 @@ import cn.iocoder.yudao.module.camera.controller.admin.checklog.vo.CheckLogRespV
import cn.iocoder.yudao.module.camera.controller.admin.checklog.vo.CheckLogSaveReqVO;
import cn.iocoder.yudao.module.camera.dal.dataobject.checklog.CheckLogDO;
import cn.iocoder.yudao.module.camera.service.checklog.CheckLogService;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.service.checkconf.CheckConfService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -74,17 +77,32 @@ public class CheckLogController {
@Resource
private DictDataService dictDataService;
@Resource
private CheckConfService checkConfService;
@PostMapping("/getColumns")
@Operation(summary = "获得盘点类型")
@PreAuthorize("@ss.hasPermission('logistics:check-log:query')")
public CommonResult<List<DictDataDO>> getColumns() {
//
Map<String, DictDataDO> dictDataNameList = dictDataService.getDictValueMap("scan_conf_name");
// List<DictDataDO> dictDataDOS = dictDataList.values().stream().filter(dictDataDO -> !dictDataDO.getValue().equals("0")).toList();
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.ne("check_scan_type", 0)
.eq("is_Enable", 1));
List<DictDataDO> dictDataDOS =checkConfList.stream().map(checkConfDO ->{
DictDataDO dictDataDO = new DictDataDO();
dictDataDO.setLabel(checkConfDO.getCheckName());
dictDataDO.setValue(checkConfDO.getCheckName());
dictDataDO.setRemark(dictDataNameList.get(checkConfDO.getCheckName()).getLabel().replaceAll("[a-zA-Z]", ""));
return dictDataDO;
} ).toList();
Map<String, DictDataDO> dictDataList = dictDataService.getDictDataList("scan_conf");
Map<String, DictDataDO> dictDataNameList = dictDataService.getDictDataList("scan_conf_name");
for (DictDataDO dictDataDO : dictDataList.values()){
dictDataDO.setRemark(dictDataNameList.get(dictDataDO.getLabel()).getValue());
}
List<DictDataDO> dictDataDOS = dictDataList.values().stream().filter(dictDataDO -> !dictDataDO.getValue().equals("0")).toList();
return success(dictDataDOS);
}

@ -1,9 +1,16 @@
package cn.iocoder.yudao.module.camera.controller.admin.kesc;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.camera.dal.dataobject.checklog.CheckLogDO;
import cn.iocoder.yudao.module.camera.framework.netty.ksec.KsecDataInfo;
import cn.iocoder.yudao.module.camera.service.checklog.CheckLogService;
import cn.iocoder.yudao.module.camera.service.plc.PLCService;
import cn.iocoder.yudao.module.camera.service.scan.LxService.BoxCountResponse;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.service.checkconf.CheckConfService;
import cn.iocoder.yudao.module.system.service.sse.SseClient;
import cn.iocoder.yudao.module.system.util.RedisUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@ -12,8 +19,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
@Tag(name = "管理后台 - 随行盘点")
@RestController
@ -26,6 +36,8 @@ public class StockControlController {
PLCService plcService;
@Resource
SseClient sseClient;
@Resource
RedisUtil redisUtil;
// 创建一个固定大小的线程池用于异步执行PLC操作
private final ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
@ -104,16 +116,64 @@ public class StockControlController {
// 开始盘点
if (kescEntity.getCmdName().equals("E1")){
plcService.checkStart(kescEntity);
plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName());
plcService.check(kescEntity.getTaskId(), kescEntity.getCmdName());
//
// plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName());
}else if (kescEntity.getCmdName().contains("E2")){
// 到位盘点
plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName());
plcService.check(kescEntity.getTaskId(), kescEntity.getCmdName());
// plcService.check(kescEntity.getTaskId(),kescEntity.getCmdName());
plcService.checkStop(kescEntity.getTaskId());
}
return CommonResult.success("OK");
}
@Resource
CheckConfService checkConfService;
@PostMapping("/singleInventoryReturn")
@Operation(summary = "盘点任务")
@ResponseBody
@PermitAll
public CommonResult<String> singleInventoryReturn(@RequestBody BoxCountResponse scanData){
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.ne("check_scan_type", 0)
.eq("is_Enable", 1));
plcService.singleInventoryReturn(scanData.getTaskId(),scanData.getResult(),scanData.getScanType(),checkConfList);
return CommonResult.success("OK");
}
@Resource
CheckLogService checkLogService;
@PostMapping("/singleInventoryQrCodeReturn")
@Operation(summary = "盘点任务")
@ResponseBody
@PermitAll
public CommonResult<String> singleInventoryQrCodeReturn(@RequestBody BoxCountResponse boxCountResponse){
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.ne("check_scan_type", 0)
.eq("is_Enable", 1));
boxCountResponse.setResult("NoRead");
CheckLogDO checkLogDO = checkLogService.getOne(new QueryWrapper<CheckLogDO>()
.eq("task_id", boxCountResponse.getTaskId())
.orderByDesc("id")
.last("limit 1"));
// 增加空指针保护
List<String> wmsCode = List.of(checkLogDO.getWmsCode().split(","));
if (boxCountResponse.getQrCodeResults() != null && new HashSet<>(boxCountResponse.getQrCodeResults()).containsAll(wmsCode)) {
boxCountResponse.setResult(checkLogDO.getWmsCode());
}else if (boxCountResponse.getQrCodeResults() != null ) {
String rest = wmsCode.stream().filter(s -> !boxCountResponse.getQrCodeResults().contains(s))
.collect(Collectors.joining(","));
boxCountResponse.setResult(rest);
}
plcService.singleInventoryReturn(boxCountResponse.getTaskId(),boxCountResponse.getResult(),boxCountResponse.getScanType(),checkConfList);
return CommonResult.success("OK");
}
@PostMapping("/inventory")
@Operation(summary = "盘点任务")
@ResponseBody

@ -20,6 +20,8 @@ import cn.iocoder.yudao.module.camera.service.checklog.CheckLogService;
import cn.iocoder.yudao.module.camera.service.plc.PLCServiceImpl;
import cn.iocoder.yudao.module.camera.service.resources.URLResourcesService;
import cn.iocoder.yudao.module.camera.service.stock.StockService;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.service.checkconf.CheckConfService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@ -135,6 +137,9 @@ public class StockController {
@Resource
URLResourcesService urlResourcesService;
@Resource
CheckConfService checkConfService;
@PostMapping("/getStockStatus")
@Operation(summary = "获得盘点状态")
//@Parameter(name = "id", description = "巷道id", required = true, example = "1024")
@ -144,8 +149,11 @@ public class StockController {
Map<String, DictDataDO> dictValueMap = dictDataService.getDictValueMap("check_status");
Map<String, DictDataDO> scanConf = dictDataService.getDictDataList("scan_conf");
Map<String, DictDataDO> scanConfName = dictDataService.getDictDataList("scan_conf_name");
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.ne("check_scan_type", 0)
.eq("is_Enable", 1));
Map<String, DictDataDO> scanConfName = dictDataService.getDictValueMap("scan_conf_name");
if (scanData.getCheckLogId() != null) {
CheckLogDO checkLogDO = checkLogService.getById(scanData.getCheckLogId());
@ -166,19 +174,20 @@ public class StockController {
List<ScanStatus> scanStatuses = new ArrayList<>();
// 获取字典值
ScanData finalScan = scan;
for (DictDataDO v:scanConf.values()){
if (!v.getValue().equals("0")){
for (CheckConfDO v:checkConfList){
String remark = scanConfName.get(v.getCheckName()).getLabel().replaceAll("[a-zA-Z]", "");
ScanStatus scanStatus = BeanUtils.toBean(v, ScanStatus.class);
scanStatus.setRemark(scanConfName.get(v.getLabel()).getValue());
ScanStatus scanStatus = new ScanStatus();
scanStatus.setLabel(v.getCheckName());
scanStatus.setRemark(remark);
try {
String wmsType = "wms" + PLCServiceImpl.capitalize(v.getLabel());
String wmsType = "wms" + PLCServiceImpl.capitalize(v.getCheckName());
Field dictCode = ScanStatus.class.getDeclaredField("code");
Field dictWmsCode = ScanStatus.class.getDeclaredField("wmsCode");
Field scanCode = getFieldFromHierarchy(finalScan.getClass(), v.getLabel());
Field scanCode = getFieldFromHierarchy(finalScan.getClass(), v.getCheckName());
Field scanWmsCode = getFieldFromHierarchy(finalScan.getClass(), wmsType);
if (scanCode != null && scanWmsCode != null) {
@ -191,10 +200,7 @@ public class StockController {
dictWmsCode.set(scanStatus, scanWmsCode.get(finalScan));
// 打印 ScanStatus 的字段值
System.out.println("ScanStatus Code: " + dictCode.get(scanStatus));
System.out.println("ScanStatus WMS Code: " + dictWmsCode.get(scanStatus));
} else {
System.out.println("Fields not found in the hierarchy.");
}
@ -206,7 +212,6 @@ public class StockController {
}
scanStatuses.add(scanStatus);
}
};
if (scan != null) {
scan.setScan(scanStatuses);
scan.setColour(dictValueMap.get(String.valueOf(scan.getStatus())).getColorType());

@ -55,7 +55,6 @@ public class OrderDO extends BaseDO {
private String storageCode ;
private LocalDateTime createTime ;
private LocalDateTime endTime ;
private String updater ;
private String videoPath2 ;
private String videoPath1 ;

@ -22,6 +22,8 @@ import java.time.LocalDateTime;
@AllArgsConstructor
public class StockDO extends BaseDO {
@TableField(exist = false)
private String scanType;
/**
* id
*/

@ -16,7 +16,7 @@ public class ScanData extends StockDO {
private Integer checkLogId;
private String code;
private String trayCode;
private String type;
private Integer direction;
private String ip;
private Integer port;

@ -6,6 +6,9 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.camera.controller.admin.checklog.vo.CheckLogPageReqVO;
import cn.iocoder.yudao.module.camera.dal.dataobject.checklog.CheckLogDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 盘点 Mapper
@ -28,4 +31,20 @@ public interface CheckLogMapper extends BaseMapperX<CheckLogDO> {
.orderByDesc(CheckLogDO::getCreateTime));
}
/**
* 动态更新最后一条taskId匹配的记录
* @param taskId 任务ID
* @return 更新行数
*/
int updateLastByTaskId(@Param("taskId") String taskId, @Param("value") String value , @Param("column")String column);
/**
* 根据字段列表更新status
* @param id 记录ID
* @param fields 要比较的字段列表(如:code, trayCode, category)
* 对应规则:code -> wmsCode, trayCode -> wmsTrayCode
* @return 更新行数
*/
int updateStatusByFields(@Param("id") Integer id, @Param("fields") List<String> fields);
}

@ -1,11 +1,14 @@
package cn.iocoder.yudao.module.camera.dal.mysql.stock;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.camera.controller.admin.stock.vo.StockPageReqVO;
import cn.iocoder.yudao.module.camera.dal.dataobject.stock.StockDO;
import org.apache.ibatis.annotations.Mapper;
import cn.iocoder.yudao.module.camera.controller.admin.stock.vo.*;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 盘点状态 Mapper
@ -47,4 +50,20 @@ public interface StockMapper extends BaseMapperX<StockDO> {
.orderByDesc(StockDO::getId));
}
/**
* 动态更新最后一条taskId匹配的记录
* @param taskId 任务ID
* @return 更新行数
*/
int updateLastByTaskId(@Param("taskId") String taskId, @Param("value") String value , @Param("column")String column);
/**
* 根据字段列表更新status
* @param id 记录ID
* @param fields 要比较的字段列表(如:code, trayCode, category)
* 对应规则:code -> wmsCode, trayCode -> wmsTrayCode
* @return 更新行数
*/
int updateStatusByFields(@Param("id") Long id, @Param("fields") List<String> fields);
}

@ -123,6 +123,13 @@ public class GetPhotoDelayExecutor {
exec.execute(new TaskConsumer(this.dictDataService));
}
/**
* 关闭线程池
*/
public static void shutdown() {
exec.shutdownNow();
}
/**
* Consumer内部类,实现Runnable接口,用于消费延时队列中的任务。
*/
@ -139,7 +146,7 @@ public class GetPhotoDelayExecutor {
*/
@Override
public void run() {
while (true) {
while (!Thread.currentThread().isInterrupted()) {
try {
// 从延时队列中获取并移除一个任务
AlgorithmDelayTask algorithmDelayTask = delayTasks.take();
@ -147,7 +154,8 @@ public class GetPhotoDelayExecutor {
CheckIntellBlinkDecoder.setHttp(algorithmDelayTask, dictDataService.parseDictData("camera_conf", "storageUrl").getValue());
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
break;
}
}
}

@ -53,4 +53,12 @@ public interface CheckLogService extends IService<CheckLogDO> {
PageResult<CheckLogDO> getCheckLogPage(CheckLogPageReqVO pageReqVO);
Long count(String startTime, String endTime);
/**
* 更新最后一条taskId匹配的记录
*
* @param taskId 任务ID
*/
void updateLastByTaskId(String taskId, String value,String column);
}

@ -19,8 +19,7 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -83,6 +82,11 @@ public class CheckLogServiceImpl extends ServiceImpl<CheckLogMapper, CheckLogDO>
.le(Strings.hasText(endTime),"create_time",endTime));
}
@Override
public void updateLastByTaskId(String taskId,String value,String column) {
checkLogMapper.updateLastByTaskId(taskId, value, column);
}
@Resource
DictDataService dictDataService;
@Resource
@ -94,10 +98,24 @@ public class CheckLogServiceImpl extends ServiceImpl<CheckLogMapper, CheckLogDO>
PageResult<CheckLogDO> checkLogDOPageResult = checkLogMapper.selectPage(pageReqVO);
Map<Integer,String> streetMap = streetDOS.stream().collect(Collectors.toMap(StreetDO::getId,StreetDO::getName));
// 一次性查询所有 uuid 对应的 URL 资源
List<String> uuids = checkLogDOPageResult.getList().stream()
.map(CheckLogDO::getPic)
.filter(Objects::nonNull)
.collect(Collectors.toList());
Map<String, List<URLResourcesDo>> urlResourcesMap = new HashMap<>();
if (!uuids.isEmpty()) {
List<URLResourcesDo> allUrlResources = urlResourcesService.list(
new QueryWrapper<URLResourcesDo>().in("uuid", uuids));
urlResourcesMap = allUrlResources.stream()
.collect(Collectors.groupingBy(URLResourcesDo::getUuid));
}
//记录巷道名
for (CheckLogDO checkLogDO : checkLogDOPageResult.getList()){
List<URLResourcesDo> urlResources = urlResourcesService.list(new QueryWrapper<URLResourcesDo>().eq("uuid", checkLogDO.getPic()));
checkLogDO.setUrlResources(urlResources);
checkLogDO.setUrlResources(urlResourcesMap.getOrDefault(checkLogDO.getPic(), Collections.emptyList()));
checkLogDO.setStatusName(dict.get(checkLogDO.getStatus().toString()).getLabel());
checkLogDO.setStreetName(streetMap.get(checkLogDO.getStreetId()));
}

@ -29,7 +29,6 @@ import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.ORDER_NOT_EXISTS;
@ -171,19 +170,18 @@ public class OrderServiceImpl implements OrderService {
// eChartsOption.getYAxis().setSplitNumber(eChartsOption.setsSplitNumber());
return eChartsOption;
}
@Override
public EChartsOption laneInventoryLine() {
EChartsOption eChartsOption = new EChartsOption();
Axis xAxis = new Axis();
xAxis.setType("category");
xAxis.setBoundaryGap(false);
// 创建一个包含从今天开始前7天日期的列表
// 创建一个包含从今天开始前7天日期的列表(按时间从小到大排序)
List<String> data = new ArrayList<>();
LocalDate today = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 7; i++) {
for (int i = 6; i >= 0; i--) {
LocalDate date = today.minusDays(i);
data.add(date.format(formatter));
}

@ -2,6 +2,10 @@ package cn.iocoder.yudao.module.camera.service.plc;
import cn.iocoder.yudao.module.camera.framework.netty.ksec.KsecDataInfo;
import cn.iocoder.yudao.module.camera.framework.netty.streetAlgorithm.executor.AlgorithmDelayTask;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import java.util.List;
import java.util.Map;
public interface PLCService {
@ -12,8 +16,9 @@ public interface PLCService {
void orderStart(KsecDataInfo kescEntity);
void orderStop(String taskId);
void singleInventoryReturn(String taskId, String value, String type, List<CheckConfDO> checkConfList);
void check(String taskId, String cmd);
void stopCheck(String taskId, Map<String,String> result);
void stockSave(AlgorithmDelayTask algorithmDelayTask);
}

@ -25,7 +25,10 @@ import cn.iocoder.yudao.module.camera.service.scan.ScanServiceFactory;
import cn.iocoder.yudao.module.camera.service.stock.StockService;
import cn.iocoder.yudao.module.camera.service.streamingMedia.ZLMediaKitService;
import cn.iocoder.yudao.module.camera.service.street.StreetService;
import cn.iocoder.yudao.module.camera.util.AsyncTaskUtil;
import cn.iocoder.yudao.module.camera.util.PathUtil;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.service.checkconf.CheckConfService;
import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
import cn.iocoder.yudao.module.system.service.dict.DictDataService;
import cn.iocoder.yudao.module.system.service.sse.SseClient;
@ -47,11 +50,13 @@ import java.lang.reflect.Field;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
@Slf4j
@ -81,6 +86,8 @@ public class PLCServiceImpl implements PLCService {
@Resource
SseClient sseClient;
@Resource
CheckConfService checkConfService;
// public void gyrateCamera(KescEntity kescEntity ,String code){
// StreetDO street = streetService.getStreetByPlcId(kescEntity.getData().getSRMNumber());
// if(street == null){
@ -299,6 +306,10 @@ public class PLCServiceImpl implements PLCService {
.eq(OrderDO::getTaskId, taskId)
.orderByDesc(OrderDO::getCreateTime)
.last("limit 1"));
if (order == null) {
log.error("order not found, taskId: {}", taskId);
return;
}
StreetDO street = streetService.getStreetByPlcId(order.getSrmNumber());
if (street == null) {
log.error("street not found ,plcId :{}", order.getSrmNumber());
@ -321,10 +332,11 @@ public class PLCServiceImpl implements PLCService {
} else if (picCmd.equals("C1")){
needCapture = true;
}else if (picCmd.equals("C2")){
needCapture = true;
return;
}else if (picCmd.equals("C3")){
needCapture = true;
direction = order.getToDirection();
}else if (picCmd.equals("C4")){
needCapture = true;
@ -351,6 +363,17 @@ public class PLCServiceImpl implements PLCService {
order.setPics(Strings.hasText(order.getPics()) ? order.getPics() + ";" + saveApiPath + pathSrc : saveApiPath + pathSrc);
orderMapper.updateById(order);
}
try {
if (picCmd.equals("C2")){
Thread.sleep(5000);
}else if (picCmd.equals("C3")){
Thread.sleep(5000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//转向原点位
// gyrateCameraByCode(camera, "C5");
}
@ -421,7 +444,15 @@ public class PLCServiceImpl implements PLCService {
@Override
public void checkStart(KsecDataInfo dataInfo) {
dataInfo.setCountNumber("true");
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.ne("check_scan_type", 0)
.eq("is_Enable", 1));
for (CheckConfDO checkConf : checkConfList){
redisUtil.hset("stock:task:" + dataInfo.getTaskId(), checkConf.getCheckName(), "");
}
// dataInfo = dataInfo.toWMSData();
String saveApiPath = dictDataService.parseDictData("base_conf", "data_api_path").getValue();
@ -520,13 +551,14 @@ public class PLCServiceImpl implements PLCService {
String getEndCmd(String cmd){
return cmd.replace("inventory","").toLowerCase()+ "End";
}
public void check(String taskId, String cmd) {
List<CheckConfDO> checkConfList = checkConfService.list(new QueryWrapper<CheckConfDO>()
.eq("is_Enable", 1));
//根据扫码配置 获取扫码结果
Map<String, DictDataDO> dictDataList = dictDataService.getDictDataList("scan_conf");
Map<String, DictDataDO> csscStartList = dictDataService.getDictDataList("scan_enable");
// 如果存在扫码情况,进行扫码
if (csscStartList.get(cmd) != null) {
String[] scanTypes = csscStartList.get(cmd).getValue().split(",");
if (checkConfList != null) {
StockDO stock = stockService.getOne(
new QueryWrapper<StockDO>()
.eq("task_id", taskId)
@ -543,38 +575,101 @@ public class PLCServiceImpl implements PLCService {
CameraDO camera = getCameraByLeftRight(street, stock.getDirection());
// 调用预置点位
gyrateCameraByCode(camera, cmd);
Map<String,String> result = new HashMap<>();
for (String scanType : scanTypes) {
DictDataDO dictDataDO = dictDataList.get(scanType);
ScanData scanData = scanServiceFactory.scan(dictDataDO.getValue(), street, stock);
result.put(scanType, scanData.getCode());
redisUtil.hset("stock:task:" + taskId, scanType, scanData.getCode());
try {
String wmsType = "wms" + capitalize(scanType);
Field stockField = stock.getClass().getDeclaredField(scanType);
Field stockWmsField = stock.getClass().getDeclaredField(wmsType);
// Map<String,String> result = new HashMap<>();
for (CheckConfDO checkConfDO : checkConfList) {
if (!checkConfDO.getCheckType().equals(cmd)){
continue;
}
String scanType = checkConfDO.getCheckScanType();
// 是否异步 0 同步 1 异步 2半异步,先同步,异步修改数据
if (checkConfDO.getCheckAsyn().equals("1")){
// 异步执行扫码
AsyncTaskUtil.execute(() -> {
try {
ScanData scanData = scanServiceFactory.scan(scanType, street, stock);
singleInventoryReturn(taskId, scanData.getCode(), checkConfDO.getCheckName(), checkConfList);
} catch (Exception e) {
log.error("异步扫码异常, taskId: {}, scanType: {}", taskId, scanType, e);
}
});
}else if (checkConfDO.getCheckAsyn().equals("0")){
ScanData scanData = scanServiceFactory.scan(scanType, street, stock);
singleInventoryReturn(taskId, scanData.getCode(), checkConfDO.getCheckName(), checkConfList);
}else if (checkConfDO.getCheckAsyn().equals("2")){
stock.setScanType(checkConfDO.getCheckName());
scanServiceFactory.scan(scanType, street, stock);
}
}
}
}
public void singleInventoryReturn(String taskId, String value,String type,List< CheckConfDO> checkConfList){
checkLogService.updateLastByTaskId(taskId,value,camelToSnake(type));
stockService.updateLastByTaskId(taskId,value,camelToSnake(type));
StockDO stock = stockService.getOne(
new QueryWrapper<StockDO>()
.eq("task_id", taskId)
.orderByDesc("create_time")
.last("limit 1")
);
CheckLogDO checkLogDO = checkLogService.getOne(new QueryWrapper<CheckLogDO>()
.eq("task_id", taskId).orderByDesc("create_time").last("limit 1"));
List<String> codes = checkConfList.stream()
.map(checkConfDO -> camelToSnake(checkConfDO.getCheckName()))
.collect(Collectors.toList());
if(stock!=null){
stockService.updateStatusByFields(stock.getId(),codes);
}
checkLogMapper.updateStatusByFields(checkLogDO.getId(),codes);
}
public void stopCheck(String taskId,Map<String,String> result) {
log.info("taskId:{}识别完成", taskId);
CheckLogDO checkLogDO = checkLogService.getOne(new QueryWrapper<CheckLogDO>()
.eq("task_id", taskId).last("limit 1")
.orderByDesc("create_time"));
// 空指针保护
if (checkLogDO == null) {
log.error("taskId:{} 对应的库存记录不存在", taskId);
return;
}
checkLogDO.setStatus(2);
for (String code : result.keySet()) {
try {
String wmsType = "wms" + capitalize(code);
Field stockField = checkLogDO.getClass().getDeclaredField(code);
Field stockWmsField = checkLogDO.getClass().getDeclaredField(wmsType);
stockField.setAccessible(true);
stockWmsField.setAccessible(true);
stockField.setAccessible(true);
stockWmsField.setAccessible(true);
// String wmsCode = (String) wmsField.get(stock);
// stockField.set(stock, scanData.getCode());
stockField.set(stock, scanData.getCode());
stockField.set(checkLogDO, result.get(code));
redisUtil.hset("stock:task:" + taskId, scanType, scanData.getCode());
} catch (IllegalAccessException e) {
log.error("scan error", e);
} catch (NoSuchFieldException e) {
log.error("scan error", e);
if (!stockWmsField.get(checkLogDO).toString().equals(result.get(code))) {
checkLogDO.setStatus(1);
}
// redisUtil.hset("stock:task:" + taskId, scanType, scanData.getCode());
} catch (IllegalAccessException e) {
log.error("scan error", e);
} catch (NoSuchFieldException e) {
log.error("scan error", e);
}
stockService.updateById(stock);
checkLog(stock);
if (cmd.contains("inventory")){
}
checkLogService.updateById(checkLogDO);
StreetDO street = streetService.getById(checkLogDO.getStreetId());
setUpdateStatus(stock, getEndCmd(cmd),result);
}
}
sseClient.sendMessage(street.getPlcId()+"巷道结束随行记录");
// checkLogService.updateById(checkLogDO);
checkLog(checkLogDO);
redisUtil.del("stock:task:" + taskId);
}
public ScanData scanTest(KsecDataInfo dataInfo,String type) {
StreetDO street = streetService.getStreetByPlcId(dataInfo.getSRMNumber());
@ -594,7 +689,6 @@ public class PLCServiceImpl implements PLCService {
public void checkStop(String taskId) {
Map<String,Object> resultReport = new HashMap<>();
Map<String, DictDataDO> dictDataList = dictDataService.getDictDataList("base_conf");
Map<String, DictDataDO> csscStartList = dictDataService.getDictDataList("scan_enable");
StockDO stock = stockService.getOne(
@ -655,6 +749,17 @@ public class PLCServiceImpl implements PLCService {
return sb.toString();
}
/**
* 驼峰转下划线
* 例如: checkName -> check_name, wmsCode -> wms_code
*/
public static String camelToSnake(String str) {
if (str == null || str.isEmpty()) {
return str;
}
return str.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
@Resource
CheckLogMapper checkLogMapper;
@ -681,6 +786,22 @@ public class PLCServiceImpl implements PLCService {
log.info("checkLogMapper insert");
}
public void checkLog(CheckLogDO checkLogDO) {
StockDO stock = stockService.getOne(new QueryWrapper<StockDO>()
.eq("task_id", checkLogDO.getTaskId())
.orderByDesc("export_time")
.last("limit 1"));
if (stock == null) {
return;
}
stock.setCheckPic(checkLogDO.getPic());
// checkLog.setPic(stock.getCheckPic());
stock.setStatus(String.valueOf(checkLogDO.getStatus()));
stock.setCreateTime(LocalDateTime.now());
stockService.updateById(stock);
log.info("stock insert");
}
@Override
public void stockSave(AlgorithmDelayTask algorithmDelayTask) {
@ -845,7 +966,7 @@ public class PLCServiceImpl implements PLCService {
} catch (Exception e) {
log.error("延迟截图执行失败,cameraId:{}", camera.getId(), e);
}
}, Long.valueOf(map.get("delay_capture_time").getValue()), TimeUnit.MILLISECONDS);
}, Long.parseLong(map.get("delay_capture_time").getValue()), TimeUnit.MILLISECONDS);
}else {
zLMediaKitService.pic(camera, realPath);

@ -11,6 +11,9 @@ public class BoxCountRequest {
private String cameraCalibratePath;
private String scanType;
private String taskId;
/**
* PCD文件路径
*/

@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
@ -16,6 +17,9 @@ import java.util.Map;
@AllArgsConstructor
public class BoxCountResponse {
private String taskId;
private String scanType;
/**
* 是否成功
*/
@ -83,6 +87,10 @@ public class BoxCountResponse {
private String imagePath;
private String result;
/**
* 二维码解码结果列表
*/
private List<String> qrCodeResults;
/**
* 创建失败响应

@ -10,20 +10,32 @@ import cn.iocoder.yudao.module.camera.service.specificationConf.SpecificationCon
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Slf4j
@Service
public class LxServiceApi {
@Resource
SpecificationConfService specificationConfService;
@Resource
RestTemplate restTemplate;
/**
* 使用 RestTemplateBuilder 创建 RestTemplate,设置超时时间
*/
private final RestTemplate restTemplate;
public LxServiceApi(RestTemplateBuilder restTemplateBuilder) {
this.restTemplate = restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(20000)) // 连接超时 5秒
.setReadTimeout(Duration.ofMillis(60000)) // 读取超时 60秒
.build();
}
@Resource
URLResourcesService urlResourcesService;
@ -31,16 +43,23 @@ public class LxServiceApi {
BoxCountResponse setHttp(BoxCountRequest request,String url){
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
//
BoxCountResponse boxCountResponse = new BoxCountResponse();
HttpEntity<BoxCountRequest> requestHttp = new HttpEntity<>(request, headers);
boxCountResponse = restTemplate.postForObject(url, requestHttp, BoxCountResponse.class);
try {
boxCountResponse = restTemplate.postForObject(url, requestHttp, BoxCountResponse.class);
} catch (Exception e) {
log.error("HTTP请求失败: {}, error: {}", url, e.getMessage());
}
return boxCountResponse;
}
public ScanData categoryScan(StreetDO streetDO, StockDO stockDO) {
String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/category/single";
String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/category/async";
BoxCountRequest request = new BoxCountRequest();
request.setCameraId(1);
request.setScanType(stockDO.getScanType());
request.setTaskId(stockDO.getTaskId());
BoxCountResponse boxCountResponse = setHttp(request,url);
log.info("LxServiceApi categoryScan:{}",boxCountResponse);
ScanData scanData = new ScanData();
@ -52,9 +71,12 @@ public class LxServiceApi {
public ScanData countScan(StreetDO streetDO, StockDO stockDO) {
String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/api/boxCount/calculate";
String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/api/boxCount/async";
SpecificationConfDO specificationConfDO = specificationConfService.getOne(new LambdaQueryWrapper<SpecificationConfDO>().eq(SpecificationConfDO::getType, stockDO.getWmsItemCode()));
BoxCountRequest request = new BoxCountRequest();
request.setScanType(stockDO.getScanType());
request.setTaskId(stockDO.getTaskId());
request.setBoxHeight(Double.valueOf(specificationConfDO.getHeight()));
request.setBoxLength(Double.valueOf(specificationConfDO.getLength()));
request.setBoxWidth(Double.valueOf(specificationConfDO.getWidth()));
@ -64,9 +86,35 @@ public class LxServiceApi {
ScanData scanData = new ScanData();
scanData.setCode(boxCountResponse.getResult());
urlResourcesService.save(URLResourcesDo.builder().url(boxCountResponse.getImagePath()).uuid( stockDO.getCheckPic()).type("1").little("品规拍照").build());
urlResourcesService.save(URLResourcesDo.builder().url(boxCountResponse.getImagePath()).uuid( stockDO.getCheckPic()).type("1").little("个数拍照").build());
return scanData;
}
public ScanData codeScan(StreetDO streetDO, StockDO stockDO) {
String url = "http://" + streetDO.getPlcIp() + ":" + streetDO.getPlcPort() + "/api/qrcode/async";
log.info("LxServiceApi codeScan:{}",url);
BoxCountRequest request = new BoxCountRequest();
request.setCameraId(1);
request.setScanType(stockDO.getScanType());
request.setTaskId(stockDO.getTaskId());
BoxCountResponse boxCountResponse = setHttp(request,url);
log.info("LxServiceApi codeScan response:{}",boxCountResponse);
ScanData scanData = new ScanData();
// 增加空指针保护
if (boxCountResponse != null && boxCountResponse.getImagePath() != null) {
urlResourcesService.save(URLResourcesDo.builder()
.url(boxCountResponse.getImagePath())
.uuid(stockDO.getCheckPic())
.type("1")
.little("二维码拍照")
.build());
}
return scanData;
}
}

@ -84,6 +84,9 @@ public class ScanServiceFactory {
case "11":
log.info("lx接口个数");
return lxServiceApi.countScan(streetDO, stockDO);
case "12":
log.info("lx货物码");
return lxServiceApi.codeScan(streetDO, stockDO);
// 默认情况处理
default:
return new ScanData();

@ -19,6 +19,13 @@ import java.util.Map;
*/
public interface StockService extends IService< StockDO> {
/**
* 更新最后一条taskId匹配的记录
*
* @param taskId 任务ID=
*/
void updateLastByTaskId(String taskId, String value,String column);
void updateStatusByFields(Long taskId, List<String> value);
/**
* 创建盘点状态
*

@ -54,6 +54,17 @@ public class StockServiceImpl extends ServiceImpl<StockMapper,StockDO> implement
return stock.getId();
}
@Override
public void updateLastByTaskId(String taskId, String value,String column) {
stockMapper.updateLastByTaskId(taskId, value,column);
}
@Override
public void updateStatusByFields(Long taskId, List<String> value) {
stockMapper.updateStatusByFields(taskId, value);
}
@Override
public void updateStock(StockSaveReqVO updateReqVO) {
// 校验存在

@ -24,6 +24,7 @@ import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@ -160,8 +161,8 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{
if (response != null && response.getCode() == 0 && response.getData() != null
&& response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) {
// 获取最新的文件(列表中第一个就是最新的)
String fileName = response.getData().getPaths().get(0);
// 获取最新的文件(按 hh-mm-ss 时间戳排序)
String fileName = getLatestFileByTimestamp(response.getData().getPaths());
// 检查是否是隐藏文件(以.开头)
if (fileName.startsWith(".")) {
@ -180,7 +181,7 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{
if (response != null && response.getCode() == 0 && response.getData() != null
&& response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) {
String fileName = response.getData().getPaths().get(0);
String fileName = getLatestFileByTimestamp(response.getData().getPaths());
return config.mp4SaveApi + "record/" + app + "/camera" + cameraId + "/" + yesterdayPeriod + "/" + fileName;
}
@ -372,6 +373,40 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{
}
return input;
}
/**
* 从录像文件路径列表中获取最新的文件(按文件名中的 hh-mm-ss 时间戳排序)
* <p>
* ZLM 的 MP4 录像文件名格式为 "hh-mm-ss.mp4",录制中文件为 ".hh-mm-ss.mp4"(带点前缀)。
* 本方法按时间戳升序排列取最大值,返回最新的文件名。
*
* @param paths ZLM getMp4RecordFile API 返回的文件路径列表
* @return 最新的文件名(含 .mp4 扩展名),若无有效文件返回空字符串
*/
private String getLatestFileByTimestamp(List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "";
}
return paths.stream()
.map(path -> {
// 从完整路径中提取文件名(去掉目录部分,兼容 / 和 \\)
String name = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
name = name.contains("\\") ? name.substring(name.lastIndexOf('\\') + 1) : name;
return name;
})
.filter(name -> {
// 只保留包含 hh-mm-ss 格式的文件名(如 10-30-45.mp4 或 .10-30-45.mp4)
String base = name.startsWith(".") ? name.substring(1) : name;
return base.matches(".*\\d{2}-\\d{2}-\\d{2}.*");
})
.max((a, b) -> {
// 去掉点前缀后按字符串字典序比较(hh-mm-ss 格式天然支持)
String ta = a.startsWith(".") ? a.substring(1) : a;
String tb = b.startsWith(".") ? b.substring(1) : b;
return ta.compareTo(tb);
})
.orElse("");
}
@Override
public String stopRecord(String app, String stream ,LocalDateTime time) {
ZlmConfig config = getZlmConfig();
@ -419,8 +454,8 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{
if (response != null && response.getCode() == 0 && response.getData() != null
&& response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) {
// 获取最新的文件(列表中第一个就是最新的)
String fileName = response.getData().getPaths().get(0);
// 获取最新的文件(按 hh-mm-ss 时间戳排序)
String fileName = getLatestFileByTimestamp(response.getData().getPaths());
return config.mp4SaveApi + "record/" + app + "/camera" + stream + "/" + currentPeriod + "/" + fileName;
}
@ -430,11 +465,11 @@ public class ZLMediaKitServiceImpl implements ZLMediaKitService{
if (response != null && response.getCode() == 0 && response.getData() != null
&& response.getData().getPaths() != null && !response.getData().getPaths().isEmpty()) {
String fileName = response.getData().getPaths().get(0);
String fileName = getLatestFileByTimestamp(response.getData().getPaths());
return config.mp4SaveApi + "record/" + app + "/camera" + stream + "/" + yesterdayPeriod + "/" + fileName;
}
log.warn("未找到录像文件: app={}, stream={}", app, stream);
log.error("未找到录像文件: app={}, stream={}", app, stream);
return "";
}

@ -0,0 +1,121 @@
package cn.iocoder.yudao.module.logistics.controller.admin.checkconf;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.camera.dal.entity.ScanData;
import cn.iocoder.yudao.module.camera.service.plc.PLCService;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfPageReqVO;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfRespVO;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfSaveReqVO;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.service.checkconf.CheckConfService;
import cn.iocoder.yudao.module.system.util.RedisUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 盘点配置")
@RestController
@RequestMapping("/logistics/check-conf")
@Validated
@Slf4j
public class CheckConfController {
@Resource
private CheckConfService checkConfService;
@PostMapping("/create")
@Operation(summary = "创建盘点配置")
@PreAuthorize("@ss.hasPermission('logistics:check-conf:create')")
public CommonResult<Integer> createCheckConf(@Valid @RequestBody CheckConfSaveReqVO createReqVO) {
return success(checkConfService.createCheckConf(createReqVO));
}
@PutMapping("/update")
@Operation(summary = "更新盘点配置")
@PreAuthorize("@ss.hasPermission('logistics:check-conf:update')")
public CommonResult<Boolean> updateCheckConf(@Valid @RequestBody CheckConfSaveReqVO updateReqVO) {
checkConfService.updateCheckConf(updateReqVO);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除盘点配置")
@Parameter(name = "id", description = "编号", required = true)
@PreAuthorize("@ss.hasPermission('logistics:check-conf:delete')")
public CommonResult<Boolean> deleteCheckConf(@RequestParam("id") Integer id) {
checkConfService.deleteCheckConf(id);
return success(true);
}
@GetMapping("/get")
@Operation(summary = "获得盘点配置")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('logistics:check-conf:query')")
public CommonResult<CheckConfRespVO> getCheckConf(@RequestParam("id") Integer id) {
CheckConfDO checkConf = checkConfService.getCheckConf(id);
return success(BeanUtils.toBean(checkConf, CheckConfRespVO.class));
}
@GetMapping("/page")
@Operation(summary = "获得盘点配置分页")
@PreAuthorize("@ss.hasPermission('logistics:check-conf:query')")
public CommonResult<PageResult<CheckConfRespVO>> getCheckConfPage(@Valid CheckConfPageReqVO pageReqVO) {
PageResult<CheckConfDO> pageResult = checkConfService.getCheckConfPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, CheckConfRespVO.class));
}
@GetMapping("/export-excel")
@Operation(summary = "导出盘点配置 Excel")
@PreAuthorize("@ss.hasPermission('logistics:check-conf:export')")
@ApiAccessLog(operateType = EXPORT)
public void exportCheckConfExcel(@Valid CheckConfPageReqVO pageReqVO,
HttpServletResponse response) throws IOException {
pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
List<CheckConfDO> list = checkConfService.getCheckConfPage(pageReqVO).getList();
// 导出 Excel
ExcelUtils.write(response, "盘点配置.xls", "数据", CheckConfRespVO.class,
BeanUtils.toBean(list, CheckConfRespVO.class));
}
@Resource
RedisUtil redisUtil;
@Resource
PLCService plcService;
@PostMapping("/check")
@Operation(summary = "导出盘点配置 Excel")
@PermitAll
@ApiAccessLog(operateType = EXPORT)
public void checkReturn(@Valid ScanData scanData) throws IOException {
String code = scanData != null ? scanData.getCode() : null;
redisUtil.hset("stock:task:" + scanData.getTaskId(), scanData.getType(), code);
log.info("同步扫码完成, taskId: {}, scanType: {}, code: {}", scanData.getTaskId(), scanData.getType(), code);
Map<String, String> result = redisUtil.hmget("stock:task:" + scanData.getTaskId());
if (result != null && !result.containsValue(null) && !result.containsValue("")) {
plcService.stopCheck(scanData.getTaskId(), result);
redisUtil.del("stock:task:" + scanData.getTaskId());
}
}
}

@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo;
import lombok.*;
import java.util.*;
import io.swagger.v3.oas.annotations.media.Schema;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 盘点配置分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class CheckConfPageReqVO extends PageParam {
@Schema(description = "盘点排序")
private Integer checkOrder;
@Schema(description = "异步类型")
private String checkAsyn;
@Schema(description = "盘点编码")
private String checkCode;
@Schema(description = "盘点名称", example = "李四")
private String checkName;
@Schema(description = "盘点类型", example = "1")
private String checkScanType;
@Schema(description = "是否启用")
private Boolean isEnable;
@Schema(description = "上位状态码", example = "1")
private String checkType;
}

@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 盘点配置 Response VO")
@Data
@ExcelIgnoreUnannotated
public class CheckConfRespVO {
@Schema(description = "id")
@ExcelProperty("id")
private Integer id;
@Schema(description = "盘点排序")
@ExcelProperty("盘点排序")
private Integer checkOrder;
@Schema(description = "异步类型")
@ExcelProperty(value = "异步类型", converter = DictConvert.class)
@DictFormat("infra_config_type") // TODO 代码优化:建议设置到对应的 DictTypeConstants 枚举类中
private String checkAsyn;
@Schema(description = "盘点编码")
@ExcelProperty("盘点编码")
private String checkCode;
@Schema(description = "盘点名称", example = "李四")
@ExcelProperty("盘点名称")
private String checkName;
@Schema(description = "盘点类型", example = "1")
@ExcelProperty("盘点类型")
private String checkScanType;
@Schema(description = "是否启用")
@ExcelProperty("是否启用")
private Boolean isEnable;
@Schema(description = "上位状态码", example = "1")
@ExcelProperty(value = "上位状态码", converter = DictConvert.class)
@DictFormat("cssc_code") // TODO 代码优化:建议设置到对应的 DictTypeConstants 枚举类中
private String checkType;
}

@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 盘点配置新增/修改 Request VO")
@Data
public class CheckConfSaveReqVO {
@Schema(description = "盘点排序")
private Integer id;
@Schema(description = "盘点排序")
private Integer checkOrder;
@Schema(description = "异步类型")
private String checkAsyn;
@Schema(description = "盘点编码")
private String checkCode;
@Schema(description = "盘点名称", example = "李四")
private String checkName;
@Schema(description = "盘点类型", example = "1")
private String checkScanType;
@Schema(description = "是否启用")
private Boolean isEnable;
@Schema(description = "上位状态码", example = "1")
private String checkType;
}

@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf;
import lombok.*;
import java.util.*;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.*;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
/**
* 盘点配置 DO
*
* @author 管理员
*/
@TableName("logistics_check_conf")
@KeySequence("logistics_check_conf_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CheckConfDO extends BaseDO {
/**
* id
*/
@TableId
private Integer id;
/**
* 盘点排序
*/
private Integer checkOrder;
/**
* 异步类型
*
* 枚举 {@link TODO infra_config_type 对应的类}
*/
private String checkAsyn;
/**
* 盘点编码
*/
private String checkCode;
/**
* 盘点名称
*/
private String checkName;
/**
* 盘点类型
*/
private String checkScanType;
/**
* 是否启用
*/
private Boolean isEnable;
/**
* 上位状态码
*
* 枚举 {@link TODO cssc_code 对应的类}
*/
private String checkType;
}

@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.logistics.dal.mysql.checkconf;
import java.util.*;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import org.apache.ibatis.annotations.Mapper;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.*;
/**
* 盘点配置 Mapper
*
* @author 管理员
*/
@Mapper
public interface CheckConfMapper extends BaseMapperX<CheckConfDO> {
default PageResult<CheckConfDO> selectPage(CheckConfPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<CheckConfDO>()
.eqIfPresent(CheckConfDO::getCheckOrder, reqVO.getCheckOrder())
.eqIfPresent(CheckConfDO::getCheckAsyn, reqVO.getCheckAsyn())
.eqIfPresent(CheckConfDO::getCheckCode, reqVO.getCheckCode())
.likeIfPresent(CheckConfDO::getCheckName, reqVO.getCheckName())
.eqIfPresent(CheckConfDO::getCheckScanType, reqVO.getCheckScanType())
.eqIfPresent(CheckConfDO::getIsEnable, reqVO.getIsEnable())
.eqIfPresent(CheckConfDO::getCheckType, reqVO.getCheckType())
.orderByDesc(CheckConfDO::getId));
}
}

@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.logistics.service.checkconf;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfPageReqVO;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfSaveReqVO;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.validation.Valid;
/**
* 盘点配置 Service 接口
*
* @author 管理员
*/
public interface CheckConfService extends IService<CheckConfDO> {
/**
* 创建盘点配置
*
* @param createReqVO 创建信息
* @return 编号
*/
Integer createCheckConf(@Valid CheckConfSaveReqVO createReqVO);
/**
* 更新盘点配置
*
* @param updateReqVO 更新信息
*/
void updateCheckConf(@Valid CheckConfSaveReqVO updateReqVO);
/**
* 删除盘点配置
*
* @param id 编号
*/
void deleteCheckConf(Integer id);
/**
* 获得盘点配置
*
* @param id 编号
* @return 盘点配置
*/
CheckConfDO getCheckConf(Integer id);
/**
* 获得盘点配置分页
*
* @param pageReqVO 分页查询
* @return 盘点配置分页
*/
PageResult<CheckConfDO> getCheckConfPage(CheckConfPageReqVO pageReqVO);
}

@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.logistics.service.checkconf;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfPageReqVO;
import cn.iocoder.yudao.module.logistics.controller.admin.checkconf.vo.CheckConfSaveReqVO;
import cn.iocoder.yudao.module.logistics.dal.dataobject.checkconf.CheckConfDO;
import cn.iocoder.yudao.module.logistics.dal.mysql.checkconf.CheckConfMapper;
import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 盘点配置 Service 实现类
*
* @author 管理员
*/
@Service
@Validated
public class CheckConfServiceImpl extends ServiceImpl<CheckConfMapper, CheckConfDO> implements CheckConfService {
@Resource
private CheckConfMapper checkConfMapper;
@Override
public Integer createCheckConf(CheckConfSaveReqVO createReqVO) {
// 插入
CheckConfDO checkConf = BeanUtils.toBean(createReqVO, CheckConfDO.class);
checkConfMapper.insert(checkConf);
// 返回
return checkConf.getId();
}
@Override
public void updateCheckConf(CheckConfSaveReqVO updateReqVO) {
// 校验存在
// validateCheckConfExists(updateReqVO.getId());
// 更新
CheckConfDO updateObj = BeanUtils.toBean(updateReqVO, CheckConfDO.class);
checkConfMapper.updateById(updateObj);
}
@Override
public void deleteCheckConf(Integer id) {
// 校验存在
validateCheckConfExists(id);
// 删除
checkConfMapper.deleteById(id);
}
private void validateCheckConfExists(Integer id) {
if (checkConfMapper.selectById(id) == null) {
throw exception(ErrorCodeConstants.CHECK_CONF_NOT_EXISTS);
}
}
@Override
public CheckConfDO getCheckConf(Integer id) {
return checkConfMapper.selectById(id);
}
@Override
public PageResult<CheckConfDO> getCheckConfPage(CheckConfPageReqVO pageReqVO) {
return checkConfMapper.selectPage(pageReqVO);
}
}

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.logistics.dal.mysql.checkconf.CheckConfMapper">
<!--
一般情况下,尽可能使用 Mapper 进行 CRUD 增删改查即可。
无法满足的场景,例如说多表关联查询,才使用 XML 编写 SQL。
代码生成器暂时只生成 Mapper XML 文件本身,更多推荐 MybatisX 快速开发插件来生成查询。
文档可见:https://www.iocoder.cn/MyBatis/x-plugins/
-->
</mapper>

@ -1,12 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.logistics.dal.mysql.checklog.CheckLogMapper">
<mapper namespace="cn.iocoder.yudao.module.camera.dal.mysql.checklog.CheckLogMapper">
<!--
一般情况下,尽可能使用 Mapper 进行 CRUD 增删改查即可。
无法满足的场景,例如说多表关联查询,才使用 XML 编写 SQL。
代码生成器暂时只生成 Mapper XML 文件本身,更多推荐 MybatisX 快速开发插件来生成查询。
文档可见:https://www.iocoder.cn/MyBatis/x-plugins/
-->
<!-- 动态更新最后一条taskId匹配的记录 -->
<update id="updateLastByTaskId">
UPDATE logistics_check_log
<set>
${column} = #{value}
</set>
WHERE id = (SELECT id FROM (SELECT id FROM logistics_check_log WHERE task_id = #{taskId} ORDER BY id DESC LIMIT 1) AS tmp)
</update>
<!-- 根据字段列表更新status -->
<!-- 规则:有字段为空或null → status=0,全部和wms字段相等 → status=2,否则 → status=1 -->
<update id="updateStatusByFields">
UPDATE logistics_check_log
SET status = CASE
WHEN
<foreach collection="fields" item="field" separator=" OR ">
${field} IS NULL OR ${field} = ''
</foreach>
THEN 0
WHEN
<foreach collection="fields" item="field" separator=" AND ">
${field} = wms_${field}
</foreach>
THEN 2
ELSE 1
END
WHERE id = #{id}
</update>
</mapper>

@ -1,12 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.logistics.dal.mysql.stock.StockMapper">
<mapper namespace="cn.iocoder.yudao.module.camera.dal.mysql.stock.StockMapper">
<!--
一般情况下,尽可能使用 Mapper 进行 CRUD 增删改查即可。
无法满足的场景,例如说多表关联查询,才使用 XML 编写 SQL。
代码生成器暂时只生成 Mapper XML 文件本身,更多推荐 MybatisX 快速开发插件来生成查询。
文档可见:https://www.iocoder.cn/MyBatis/x-plugins/
-->
<!-- 动态更新最后一条taskId匹配的记录 -->
<update id="updateLastByTaskId">
UPDATE logistics_stock
<set>
${column} = #{value}
</set>
WHERE id = (SELECT id FROM (SELECT id FROM logistics_stock WHERE task_id = #{taskId} ORDER BY id DESC LIMIT 1) AS tmp)
</update>
<!-- 根据字段列表更新status -->
<!-- 规则:有字段为空或null → status=0,全部和wms字段相等 → status=2,否则 → status=1 -->
<!-- fields格式如:code, tray_code,对应的wms字段为:wms_code, wms_tray_code(下划线格式) -->
<update id="updateStatusByFields">
UPDATE logistics_stock
SET status = CASE
WHEN
<foreach collection="fields" item="field" separator=" OR ">
${field} IS NULL OR ${field} = ''
</foreach>
THEN 0
WHEN
<foreach collection="fields" item="field" separator=" AND ">
${field} = wms_${field}
</foreach>
THEN 2
ELSE 1
END
WHERE id = #{id}
</update>
</mapper>

@ -1,34 +0,0 @@
package cn.iocoder.yudao.module.system.api.mail;
import cn.iocoder.yudao.module.system.api.mail.dto.MailSendSingleToUserReqDTO;
import jakarta.validation.Valid;
/**
* 邮箱发送 API 接口
*
* @author 芋道源码
*/
public interface MailSendApi {
/**
* 发送单条邮箱给 Admin 用户
*
* 在 mail 为空时,使用 userId 加载对应 Admin 的邮箱
*
* @param reqDTO 发送请求
* @return 发送日志编号
*/
Long sendSingleMailToAdmin(@Valid MailSendSingleToUserReqDTO reqDTO);
/**
* 发送单条邮箱给 Member 用户
*
* 在 mail 为空时,使用 userId 加载对应 Member 的邮箱
*
* @param reqDTO 发送请求
* @return 发送日志编号
*/
Long sendSingleMailToMember(@Valid MailSendSingleToUserReqDTO reqDTO);
}

@ -1,37 +0,0 @@
package cn.iocoder.yudao.module.system.api.mail.dto;
import lombok.Data;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import java.util.Map;
/**
* 邮件发送 Request DTO
*
* @author wangjingqi
*/
@Data
public class MailSendSingleToUserReqDTO {
/**
* 用户编号
*/
private Long userId;
/**
* 邮箱
*/
@Email
private String mail;
/**
* 邮件模板编号
*/
@NotNull(message = "邮件模板编号不能为空")
private String templateCode;
/**
* 邮件模板参数
*/
private Map<String, Object> templateParams;
}

@ -1,40 +0,0 @@
package cn.iocoder.yudao.module.system.api.sms;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeValidateReqDTO;
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO;
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import jakarta.validation.Valid;
/**
* 短信验证码 API 接口
*
* @author 芋道源码
*/
public interface SmsCodeApi {
/**
* 创建短信验证码,并进行发送
*
* @param reqDTO 发送请求
*/
void sendSmsCode(@Valid SmsCodeSendReqDTO reqDTO);
/**
* 验证短信验证码,并进行使用
* 如果正确,则将验证码标记成已使用
* 如果错误,则抛出 {@link ServiceException} 异常
*
* @param reqDTO 使用请求
*/
void useSmsCode(@Valid SmsCodeUseReqDTO reqDTO);
/**
* 检查验证码是否有效
*
* @param reqDTO 校验请求
*/
void validateSmsCode(@Valid SmsCodeValidateReqDTO reqDTO);
}

@ -1,34 +0,0 @@
package cn.iocoder.yudao.module.system.api.sms;
import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
import jakarta.validation.Valid;
/**
* 短信发送 API 接口
*
* @author 芋道源码
*/
public interface SmsSendApi {
/**
* 发送单条短信给 Admin 用户
*
* 在 mobile 为空时,使用 userId 加载对应 Admin 的手机号
*
* @param reqDTO 发送请求
* @return 发送日志编号
*/
Long sendSingleSmsToAdmin(@Valid SmsSendSingleToUserReqDTO reqDTO);
/**
* 发送单条短信给 Member 用户
*
* 在 mobile 为空时,使用 userId 加载对应 Member 的手机号
*
* @param reqDTO 发送请求
* @return 发送日志编号
*/
Long sendSingleSmsToMember(@Valid SmsSendSingleToUserReqDTO reqDTO);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save