package com.youlai.boot.device.controller; import cn.hutool.core.util.IdUtil; import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.config.FilePath; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.util.HashUtils; import com.youlai.boot.device.model.entity.SnDeviceHardware; import com.youlai.boot.device.model.req.ApkInstallInfoReq; import com.youlai.boot.device.model.req.SnHardwareInfoReq; import com.youlai.boot.device.model.req.SnLocationReq; import com.youlai.boot.device.model.entity.SnDeveloper; import com.youlai.boot.device.model.entity.SnLocation; import com.youlai.boot.device.model.entity.SnScreenshot; import com.youlai.boot.device.model.vo.DeveloperOptionsVO; import com.youlai.boot.device.service.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.apache.commons.io.FilenameUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; /** * 设备控制层 * @author TTSTD * @since 2026/04/05 */ @Tag(name = "16.移动设备管理") @RestController @RequestMapping("/api/v1/sn") @RequiredArgsConstructor public class MobileController { private static final String DEVICE_SECRET_PREFIX = "device:secret:"; private final RedisTemplate redisTemplate; private final DeviceService deviceService; private final ScreenshotService screenshotService; private final LocationService locationService; private final DeveloperService developerService; private final ApkInstallService apkInstallService; private final HardwareService hardwareService; private final Logger logger = LoggerFactory.getLogger(MobileController.class); @Operation(summary = "注册设备") @PostMapping("/register") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.REGISTER) public Map registerDevice(@RequestParam() String sn) { // 生成设备密钥 String deviceSecret = IdUtil.fastSimpleUUID(); // 存储到Redis(可根据需要设置过期时间) redisTemplate.opsForValue().set(DEVICE_SECRET_PREFIX + sn, deviceSecret, 365, TimeUnit.DAYS); Map result = new HashMap<>(); result.put("deviceId", sn); result.put("deviceSecret", deviceSecret); result.put("message", "请妥善保管设备密钥,用于生成API签名"); return result; } @Operation(summary = "上传设备硬件信息") @PostMapping("/update_hardware_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER) public Result updateHardwareInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnHardwareInfoReq hardwareInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (hardwareInfoReq == null) { return Result.failed("硬件信息不能为空"); } SnDeviceHardware hardware = new SnDeviceHardware(); BeanUtils.copyProperties(hardwareInfoReq, hardware); hardware.setSerialno(sn); hardware.setUpdateTime(LocalDateTime.now()); SnDeviceHardware existingHardware = hardwareService.lambdaQuery() .eq(SnDeviceHardware::getSerialno, sn) .one(); boolean saved; if (existingHardware != null) { hardware.setId(existingHardware.getId()); saved = hardwareService.updateById(hardware); } else { hardware.setCreateTime(LocalDateTime.now()); saved = hardwareService.save(hardware); } if (!saved) { return Result.failed("保存硬件信息失败"); } logger.info("硬件信息上传成功, sn: {}", sn); return Result.success(); } catch (Exception e) { logger.error("updateHardwareInfo error, sn: {}", sn, e); return Result.failed("上传硬件信息失败: " + e.getMessage()); } } @Operation(summary = "上传设备截图") @PostMapping("/upload_screenshot") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.SCREENSHOT) public Result uploadScreenshot( @RequestPart(value = "file") MultipartFile file, @RequestHeader(value = "X-Device-SN") String sn ) { try { if (file.isEmpty()) { return Result.failed("上传文件不能为空"); } if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } String screenshotPath = FilePath.getScreenshotPath(); logger.info("uploadScreenshot, screenshotPath: {}", screenshotPath); File fileDir = new File(screenshotPath); if (!fileDir.exists()) { boolean created = fileDir.mkdirs(); if (!created) { logger.error("创建目录失败: {}", screenshotPath); return Result.failed("创建目录失败"); } } String originName = file.getOriginalFilename(); if (originName == null || originName.isEmpty()) { return Result.failed("文件名无效"); } String fileExtension = FilenameUtils.getExtension(originName); String md5 = HashUtils.calculateMultipartFileMd5(file); String sha1 = HashUtils.calculateMultipartFileSha1(file); String sha256 = HashUtils.calculateMultipartFileSha256(file); String fileName = sn + "_" + System.currentTimeMillis() + "_" + md5 + "." + fileExtension; File destFile = new File(fileDir, fileName); file.transferTo(destFile); SnScreenshot screenshotInfo = new SnScreenshot(); screenshotInfo.setSn(sn); screenshotInfo.setFileName(fileName); screenshotInfo.setFilePath(screenshotPath + fileName); screenshotInfo.setFileSize(file.getSize()); screenshotInfo.setFileMd5(md5); screenshotInfo.setFileSha1(sha1); screenshotInfo.setFileSha256(sha256); screenshotInfo.setUploadTime(LocalDateTime.now()); screenshotService.save(screenshotInfo); logger.info("截图上传成功, sn: {}, fileName: {}", sn, fileName); return Result.success(); } catch (Exception e) { logger.error("uploadScreenshot error, sn: {}", sn, e); return Result.failed("上传截图失败: " + e.getMessage()); } } @Operation(summary = "上传设备定位信息") @PostMapping("/upload_location") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.LOCATE) public Result uploadLocation( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnLocationReq locationReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (locationReq == null) { return Result.failed("定位信息不能为空"); } SnLocation location = new SnLocation(); BeanUtils.copyProperties(locationReq, location); location.setSn(sn); SnLocation existingLocation = locationService.lambdaQuery() .eq(SnLocation::getSn, sn) .one(); boolean saved; if (existingLocation != null) { location.setId(existingLocation.getId()); saved = locationService.updateById(location); } else { saved = locationService.save(location); } if (!saved) { return Result.failed("保存定位信息失败"); } logger.info("定位信息上传成功, sn: {}, location: {}", sn, locationReq); return Result.success(); } catch (Exception e) { logger.error("uploadLocation error, sn: {}", sn, e); return Result.failed("上传定位信息失败: " + e.getMessage()); } } @Operation(summary = "获取开发者选项开关") @GetMapping("/get_developer_options") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER) public Result getDeveloperOptions(@RequestHeader(value = "X-Device-SN") String sn) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } // 查询该设备的开发者选项配置 SnDeveloper developerConfig = developerService.lambdaQuery() .eq(SnDeveloper::getSn, sn) .one(); DeveloperOptionsVO vo = new DeveloperOptionsVO(); if (developerConfig != null) { vo.setDeveloperOptions(developerConfig.getDeveloperOptions()); } else { vo.setDeveloperOptions(0); } // 返回开发者选项开关状态 return Result.success(vo); } catch (Exception e) { logger.error("getDeveloperOptions error, sn: {}", sn, e); return Result.failed("获取开发者选项失败: " + e.getMessage()); } } @Operation(summary = "上传设备已安装应用列表") @PostMapping("/upload_install_apks") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER) public Result uploadInstallApks( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody List apkInfos ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (apkInfos == null || apkInfos.isEmpty()) { return Result.failed("应用列表不能为空"); } boolean saved = apkInstallService.saveOrUpdateDeviceApkInfo(sn, apkInfos); if (!saved) { return Result.failed("保存应用信息失败"); } logger.info("应用列表上传成功, sn: {}, count: {}", sn, apkInfos.size()); return Result.success(); } catch (Exception e) { logger.error("uploadInstallApks error, sn: {}", sn, e); return Result.failed("上传应用列表失败: " + e.getMessage()); } } @Operation(summary = "获取设备已安装应用列表") @GetMapping("/get_install_apks") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER) public Result> getInstallApks(@RequestHeader(value = "X-Device-SN") String sn) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } List apkInfos = apkInstallService.getDeviceApkInfo(sn); logger.info("获取应用列表成功, sn: {}, count: {}", sn, apkInfos.size()); return Result.success(apkInfos); } catch (Exception e) { logger.error("getInstallApks error, sn: {}", sn, e); return Result.failed("获取应用列表失败: " + e.getMessage()); } } }