feat(device): 新增设备截图管理功能
- 新增截图列表查询、单张删除、一键清空接口 - 上传截图时清洗文件名防路径穿越,filePath 仅存相对路径 - 截图静态资源映射禁用缓存,避免浏览器复用旧图 - 新增 ScreenshotVO 视图对象及 ScreenshotService 实现
This commit is contained in:
@@ -33,5 +33,13 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
registry.addResourceHandler("/static/app_icon/**")
|
||||
.addResourceLocations(location)
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable());
|
||||
|
||||
// 设备截图静态资源映射:/static/screenshot/{fileName} -> 磁盘 tablet/screenshot 目录
|
||||
// 截图内容会变化(删除/重截),且文件名 md5 可能因画面相同而重复,
|
||||
// 故不使用长缓存,避免浏览器复用旧图(表现为 304 + 图片不更新)。
|
||||
String screenshotLocation = "file:" + FilePath.getScreenshotPath().replace("\\", "/");
|
||||
registry.addResourceHandler("/static/screenshot/**")
|
||||
.addResourceLocations(screenshotLocation)
|
||||
.setCacheControl(CacheControl.noCache());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import com.youlai.boot.device.model.form.AppActionForm;
|
||||
import com.youlai.boot.device.model.form.DeveloperForm;
|
||||
import com.youlai.boot.device.model.vo.DeviceLocationVO;
|
||||
import com.youlai.boot.device.model.vo.DevicePageVO;
|
||||
import com.youlai.boot.device.model.vo.ScreenshotVO;
|
||||
import com.youlai.boot.device.service.DeviceService;
|
||||
import com.youlai.boot.device.service.LocationService;
|
||||
import com.youlai.boot.device.service.ScreenshotService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -19,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备操作管理控制层(还原原 DeviceController 中的设备操作类接口)
|
||||
* 与 DeviceController(列表 + 分面板信息查询)分离,职责更清晰。
|
||||
@@ -34,6 +38,7 @@ public class DeviceOpsController {
|
||||
|
||||
private final DeviceService deviceService;
|
||||
private final LocationService locationService;
|
||||
private final ScreenshotService screenshotService;
|
||||
|
||||
@Operation(summary = "获取SN绑定激活信息")
|
||||
@GetMapping("/{sn}/info")
|
||||
@@ -107,6 +112,29 @@ public class DeviceOpsController {
|
||||
return Result.success(location);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取设备截图列表", description = "按上传时间倒序返回设备已上传的截图列表,用于网页端轮播展示")
|
||||
@GetMapping("/{sn}/screenshots")
|
||||
public Result<List<ScreenshotVO>> listScreenshots(@Parameter(description = "设备序列号", required = true) @PathVariable String sn) {
|
||||
List<ScreenshotVO> list = screenshotService.listBySn(sn);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除单张设备截图", description = "删除指定截图(数据库记录与磁盘文件),用于网页端逐张删除")
|
||||
@DeleteMapping("/{sn}/screenshots/{id}")
|
||||
public Result<Boolean> deleteScreenshot(
|
||||
@Parameter(description = "设备序列号", required = true) @PathVariable String sn,
|
||||
@Parameter(description = "截图ID", required = true) @PathVariable Long id) {
|
||||
boolean result = screenshotService.deleteById(id);
|
||||
return Result.judge(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "清空设备全部截图", description = "删除该设备的所有截图(数据库记录与磁盘文件),用于网页端一键清空")
|
||||
@DeleteMapping("/{sn}/screenshots")
|
||||
public Result<Integer> clearScreenshots(@Parameter(description = "设备序列号", required = true) @PathVariable String sn) {
|
||||
int count = screenshotService.clearBySn(sn);
|
||||
return Result.success(count);
|
||||
}
|
||||
|
||||
@Operation(summary = "设备重置")
|
||||
@PostMapping("/restore")
|
||||
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.RESTORE)
|
||||
|
||||
@@ -356,8 +356,13 @@ public class MobileController {
|
||||
if (originName == null || originName.isEmpty()) {
|
||||
return Result.failed("文件名无效");
|
||||
}
|
||||
// 清洗文件名,去除路径穿越风险(只保留纯文件名)
|
||||
String safeBaseName = FilenameUtils.getName(originName);
|
||||
if (safeBaseName.isEmpty()) {
|
||||
return Result.failed("文件名无效");
|
||||
}
|
||||
|
||||
String fileExtension = FilenameUtils.getExtension(originName);
|
||||
String fileExtension = FilenameUtils.getExtension(safeBaseName);
|
||||
String md5 = HashUtils.calculateMultipartFileMd5(file);
|
||||
String sha1 = HashUtils.calculateMultipartFileSha1(file);
|
||||
String sha256 = HashUtils.calculateMultipartFileSha256(file);
|
||||
@@ -368,8 +373,10 @@ public class MobileController {
|
||||
|
||||
SnScreenshot screenshotInfo = new SnScreenshot();
|
||||
screenshotInfo.setSn(sn);
|
||||
// fileName 仅存纯文件名;前端访问 URL = /static/screenshot/{fileName}(由 WebMvcConfig 静态映射)
|
||||
screenshotInfo.setFileName(fileName);
|
||||
screenshotInfo.setFilePath(screenshotPath + fileName);
|
||||
// filePath 仅存相对子目录,避免耦合绝对路径
|
||||
screenshotInfo.setFilePath(FilePath.TABLET_PATH + "/" + FilePath.SCREENSHOT_PATH + "/" + fileName);
|
||||
screenshotInfo.setFileSize(file.getSize());
|
||||
screenshotInfo.setFileMd5(md5);
|
||||
screenshotInfo.setFileSha1(sha1);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.youlai.boot.device.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备截图视图对象
|
||||
*
|
||||
* @author TTSTD
|
||||
* @since 2026/08/06
|
||||
*/
|
||||
@Schema(description = "设备截图信息")
|
||||
@Data
|
||||
public class ScreenshotVO {
|
||||
|
||||
@Schema(description = "截图ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "设备序列号")
|
||||
private String sn;
|
||||
|
||||
@Schema(description = "文件名称")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "可访问的图片URL")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "上传时间")
|
||||
private LocalDateTime uploadTime;
|
||||
}
|
||||
@@ -2,7 +2,33 @@ package com.youlai.boot.device.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.youlai.boot.device.model.entity.SnScreenshot;
|
||||
import com.youlai.boot.device.model.vo.ScreenshotVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ScreenshotService extends IService<SnScreenshot> {
|
||||
|
||||
/**
|
||||
* 根据设备序列号查询截图列表(按上传时间倒序)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @return 截图视图对象列表
|
||||
*/
|
||||
List<ScreenshotVO> listBySn(String sn);
|
||||
|
||||
/**
|
||||
* 删除单张截图(同时删除数据库记录与磁盘文件)
|
||||
*
|
||||
* @param id 截图ID
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
boolean deleteById(Long id);
|
||||
|
||||
/**
|
||||
* 清空指定设备的全部截图(同时删除数据库记录与磁盘文件)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @return 删除记录数
|
||||
*/
|
||||
int clearBySn(String sn);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
package com.youlai.boot.device.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.youlai.boot.common.config.FilePath;
|
||||
import com.youlai.boot.device.mapper.ScreenshotMapper;
|
||||
import com.youlai.boot.device.model.entity.SnScreenshot;
|
||||
import com.youlai.boot.device.model.vo.ScreenshotVO;
|
||||
import com.youlai.boot.device.service.ScreenshotService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ScreenshotServiceImpl extends ServiceImpl<ScreenshotMapper, SnScreenshot> implements ScreenshotService {
|
||||
|
||||
/** 截图静态资源访问前缀(与 WebMvcConfig 中映射保持一致) */
|
||||
@Value("${file.access-url-prefix:/static}")
|
||||
private String accessUrlPrefix;
|
||||
|
||||
@Override
|
||||
public List<ScreenshotVO> listBySn(String sn) {
|
||||
List<SnScreenshot> list = this.list(
|
||||
new LambdaQueryWrapper<SnScreenshot>()
|
||||
.eq(SnScreenshot::getSn, sn)
|
||||
.orderByDesc(SnScreenshot::getUploadTime)
|
||||
);
|
||||
return list.stream().map(entity -> {
|
||||
ScreenshotVO vo = new ScreenshotVO();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
// 拼接可访问的静态资源 URL:/static/screenshot/{fileName}
|
||||
vo.setUrl(accessUrlPrefix + "/screenshot/" + entity.getFileName());
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteById(Long id) {
|
||||
SnScreenshot entity = this.getById(id);
|
||||
if (entity == null) {
|
||||
return false;
|
||||
}
|
||||
// 删除磁盘文件
|
||||
deletePhysicalFile(entity.getFileName());
|
||||
// 删除数据库记录
|
||||
return this.removeById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int clearBySn(String sn) {
|
||||
List<SnScreenshot> list = this.list(
|
||||
new LambdaQueryWrapper<SnScreenshot>().eq(SnScreenshot::getSn, sn)
|
||||
);
|
||||
if (list.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
// 删除磁盘文件
|
||||
list.forEach(entity -> deletePhysicalFile(entity.getFileName()));
|
||||
// 删除数据库记录
|
||||
this.removeByIds(list.stream().map(SnScreenshot::getId).collect(Collectors.toList()));
|
||||
return list.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除磁盘上的截图文件(基于文件名拼接目录,含安全校验防路径穿越)
|
||||
*/
|
||||
private void deletePhysicalFile(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty() || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
log.warn("截图文件名非法,跳过物理删除: {}", fileName);
|
||||
return;
|
||||
}
|
||||
File file = new File(FilePath.getScreenshotPath() + fileName);
|
||||
if (file.exists() && file.isFile()) {
|
||||
if (!file.delete()) {
|
||||
log.warn("截图文件删除失败: {}", file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user