feat(device): 新增设备截图管理功能

- 新增截图列表查询、单张删除、一键清空接口
- 上传截图时清洗文件名防路径穿越,filePath 仅存相对路径
- 截图静态资源映射禁用缓存,避免浏览器复用旧图
- 新增 ScreenshotVO 视图对象及 ScreenshotService 实现
This commit is contained in:
TongTongStudio
2026-08-06 01:42:46 +08:00
parent 9d6498872f
commit e9a1773d88
6 changed files with 177 additions and 2 deletions

View File

@@ -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());
}
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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());
}
}
}
}