diff --git a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java index 3791c00c..864b101a 100644 --- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java +++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java @@ -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()); } } diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java index 7529225b..46cf82c3 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java @@ -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> listScreenshots(@Parameter(description = "设备序列号", required = true) @PathVariable String sn) { + List list = screenshotService.listBySn(sn); + return Result.success(list); + } + + @Operation(summary = "删除单张设备截图", description = "删除指定截图(数据库记录与磁盘文件),用于网页端逐张删除") + @DeleteMapping("/{sn}/screenshots/{id}") + public Result 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 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) diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java index 4e587723..611fb6fb 100644 --- a/src/main/java/com/youlai/boot/device/controller/MobileController.java +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -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); diff --git a/src/main/java/com/youlai/boot/device/model/vo/ScreenshotVO.java b/src/main/java/com/youlai/boot/device/model/vo/ScreenshotVO.java new file mode 100644 index 00000000..092d79d0 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/ScreenshotVO.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/service/ScreenshotService.java b/src/main/java/com/youlai/boot/device/service/ScreenshotService.java index a20df820..b89cf70d 100644 --- a/src/main/java/com/youlai/boot/device/service/ScreenshotService.java +++ b/src/main/java/com/youlai/boot/device/service/ScreenshotService.java @@ -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 { + /** + * 根据设备序列号查询截图列表(按上传时间倒序) + * + * @param sn 设备序列号 + * @return 截图视图对象列表 + */ + List listBySn(String sn); + + /** + * 删除单张截图(同时删除数据库记录与磁盘文件) + * + * @param id 截图ID + * @return 是否删除成功 + */ + boolean deleteById(Long id); + + /** + * 清空指定设备的全部截图(同时删除数据库记录与磁盘文件) + * + * @param sn 设备序列号 + * @return 删除记录数 + */ + int clearBySn(String sn); } diff --git a/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java index 95af1153..89078e82 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/ScreenshotServiceImpl.java @@ -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 implements ScreenshotService { + /** 截图静态资源访问前缀(与 WebMvcConfig 中映射保持一致) */ + @Value("${file.access-url-prefix:/static}") + private String accessUrlPrefix; + + @Override + public List listBySn(String sn) { + List list = this.list( + new LambdaQueryWrapper() + .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 list = this.list( + new LambdaQueryWrapper().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()); + } + } + } }