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.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.util.HashUtils; import com.youlai.boot.device.model.entity.SnDeviceSystemInfo; import com.youlai.boot.device.model.entity.SnDeviceHardwareInfo; import com.youlai.boot.device.model.entity.SnDeviceNetworkInfo; import com.youlai.boot.device.model.entity.SnDeviceSecurityInfo; import com.youlai.boot.device.model.entity.SnDeviceOtherInfo; import com.youlai.boot.device.model.entity.SnDeviceInfo; import com.youlai.boot.device.model.req.ApkInstallInfoReq; import com.youlai.boot.device.model.req.SnSystemInfoReq; import com.youlai.boot.device.model.req.SnHardwareInfoReq; import com.youlai.boot.device.model.req.SnNetworkInfoReq; import com.youlai.boot.device.model.req.SnSecurityInfoReq; import com.youlai.boot.device.model.req.SnOtherInfoReq; import com.youlai.boot.device.model.req.SnLocationReq; 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 AppIconService appIconService; private final SystemInfoService systemInfoService; private final HardwareInfoService hardwareInfoService; private final NetworkInfoService networkInfoService; private final SecurityInfoService securityInfoService; private final OtherInfoService otherInfoService; 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_system_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_SYSTEM_INFO) public Result updateSystemInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnSystemInfoReq snSystemInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (snSystemInfoReq == null) { return Result.failed("硬件信息不能为空"); } if (snSystemInfoReq.getDeviceModel() != null && !snSystemInfoReq.getDeviceModel().trim().isEmpty()) { SnDeviceInfo deviceInfo = deviceService.lambdaQuery() .eq(SnDeviceInfo::getSerialno, sn) .one(); if (deviceInfo != null) { deviceInfo.setSnModel(snSystemInfoReq.getDeviceModel()); deviceInfo.setUpdateTime(LocalDateTime.now()); deviceService.updateById(deviceInfo); } } SnDeviceSystemInfo systemInfo = new SnDeviceSystemInfo(); BeanUtils.copyProperties(snSystemInfoReq, systemInfo); systemInfo.setSerialno(sn); systemInfo.setUpdateTime(LocalDateTime.now()); SnDeviceSystemInfo existingSystemInfo = systemInfoService.lambdaQuery() .eq(SnDeviceSystemInfo::getSerialno, sn) .one(); boolean saved; if (existingSystemInfo != null) { systemInfo.setId(existingSystemInfo.getId()); saved = systemInfoService.updateById(systemInfo); } else { systemInfo.setCreateTime(LocalDateTime.now()); saved = systemInfoService.save(systemInfo); } if (!saved) { return Result.failed("保存硬件信息失败"); } logger.info("硬件信息上传成功, sn: {}", sn); return Result.success(); } catch (Exception e) { logger.error("updateSystemInfo error, sn: {}", sn, e); return Result.failed("上传硬件信息失败: " + e.getMessage()); } } @Operation(summary = "上传设备其他信息") @PostMapping("/update_other_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_OTHER_INFO) public Result updateOtherInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnOtherInfoReq snOtherInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (snOtherInfoReq == null) { return Result.failed("其他信息不能为空"); } SnDeviceOtherInfo otherInfo = new SnDeviceOtherInfo(); BeanUtils.copyProperties(snOtherInfoReq, otherInfo); otherInfo.setSerialno(sn); otherInfo.setUpdateTime(LocalDateTime.now()); SnDeviceOtherInfo existingOtherInfo = otherInfoService.lambdaQuery() .eq(SnDeviceOtherInfo::getSerialno, sn) .one(); boolean saved; if (existingOtherInfo != null) { otherInfo.setId(existingOtherInfo.getId()); saved = otherInfoService.updateById(otherInfo); } else { otherInfo.setCreateTime(LocalDateTime.now()); saved = otherInfoService.save(otherInfo); } if (!saved) { return Result.failed("保存其他信息失败"); } // 如果推送ID不为空,则同步更新设备表的推送ID if (snOtherInfoReq.getPushId() != null && !snOtherInfoReq.getPushId().trim().isEmpty()) { SnDeviceInfo deviceInfo = deviceService.lambdaQuery() .eq(SnDeviceInfo::getSerialno, sn) .one(); if (deviceInfo != null) { deviceInfo.setPushId(snOtherInfoReq.getPushId()); deviceInfo.setUpdateTime(LocalDateTime.now()); deviceService.updateById(deviceInfo); } } logger.info("其他信息上传成功, sn: {}", sn); return Result.success(); } catch (Exception e) { logger.error("updateOtherInfo error, sn: {}", sn, e); return Result.failed("上传其他信息失败: " + e.getMessage()); } } @Operation(summary = "上传设备硬件信息") @PostMapping("/update_hardware_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_HARDWARE_INFO) public Result updateHardwareInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnHardwareInfoReq snHardwareInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (snHardwareInfoReq == null) { return Result.failed("硬件信息不能为空"); } SnDeviceHardwareInfo hardwareInfo = new SnDeviceHardwareInfo(); BeanUtils.copyProperties(snHardwareInfoReq, hardwareInfo); hardwareInfo.setSerialno(sn); hardwareInfo.setUpdateTime(LocalDateTime.now()); SnDeviceHardwareInfo existingHardwareInfo = hardwareInfoService.lambdaQuery() .eq(SnDeviceHardwareInfo::getSerialno, sn) .one(); boolean saved; if (existingHardwareInfo != null) { hardwareInfo.setId(existingHardwareInfo.getId()); saved = hardwareInfoService.updateById(hardwareInfo); } else { hardwareInfo.setCreateTime(LocalDateTime.now()); saved = hardwareInfoService.save(hardwareInfo); } 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("/update_network_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_NETWORK_INFO) public Result updateNetworkInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnNetworkInfoReq snNetworkInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (snNetworkInfoReq == null) { return Result.failed("网络信息不能为空"); } SnDeviceNetworkInfo networkInfo = new SnDeviceNetworkInfo(); BeanUtils.copyProperties(snNetworkInfoReq, networkInfo); networkInfo.setSerialno(sn); networkInfo.setUpdateTime(LocalDateTime.now()); SnDeviceNetworkInfo existingNetworkInfo = networkInfoService.lambdaQuery() .eq(SnDeviceNetworkInfo::getSerialno, sn) .one(); boolean saved; if (existingNetworkInfo != null) { networkInfo.setId(existingNetworkInfo.getId()); saved = networkInfoService.updateById(networkInfo); } else { networkInfo.setCreateTime(LocalDateTime.now()); saved = networkInfoService.save(networkInfo); } if (!saved) { return Result.failed("保存网络信息失败"); } logger.info("网络信息上传成功, sn: {}", sn); return Result.success(); } catch (Exception e) { logger.error("updateNetworkInfo error, sn: {}", sn, e); return Result.failed("上传网络信息失败: " + e.getMessage()); } } @Operation(summary = "上传设备安全信息") @PostMapping("/update_security_info") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_SECURITY_INFO) public Result updateSecurityInfo( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody SnSecurityInfoReq snSecurityInfoReq ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (snSecurityInfoReq == null) { return Result.failed("安全信息不能为空"); } SnDeviceSecurityInfo securityInfo = new SnDeviceSecurityInfo(); BeanUtils.copyProperties(snSecurityInfoReq, securityInfo); securityInfo.setSerialno(sn); securityInfo.setUpdateTime(LocalDateTime.now()); SnDeviceSecurityInfo existingSecurityInfo = securityInfoService.lambdaQuery() .eq(SnDeviceSecurityInfo::getSerialno, sn) .one(); boolean saved; if (existingSecurityInfo != null) { securityInfo.setId(existingSecurityInfo.getId()); saved = securityInfoService.updateById(securityInfo); } else { securityInfo.setCreateTime(LocalDateTime.now()); saved = securityInfoService.save(securityInfo); } if (!saved) { return Result.failed("保存安全信息失败"); } logger.info("安全信息上传成功, sn: {}", sn); return Result.success(); } catch (Exception e) { logger.error("updateSecurityInfo 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 safeBaseName = FilenameUtils.getName(originName); if (safeBaseName.isEmpty()) { return Result.failed("文件名无效"); } String fileExtension = FilenameUtils.getExtension(safeBaseName); 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); // fileName 仅存纯文件名;前端访问 URL = /static/screenshot/{fileName}(由 WebMvcConfig 静态映射) screenshotInfo.setFileName(fileName); // filePath 仅存相对子目录,避免耦合绝对路径 screenshotInfo.setFilePath(FilePath.TABLET_PATH + "/" + FilePath.SCREENSHOT_PATH + "/" + 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); // 上次定位成功时间由服务端统一写入,避免移动端时间不准或 LocalDateTime 序列化格式不一致导致为空 location.setLastSuccessfulTime(LocalDateTime.now()); 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.VIEW_DEVELOPER_OPTIONS) public Result getDeveloperOptions(@RequestHeader(value = "X-Device-SN") String sn) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } // 查询该设备的开发者选项配置,未配置时默认关闭 DeveloperOptionsVO vo = new DeveloperOptionsVO(); vo.setDeveloperOptions(developerService.getDeveloperOptionsBySn(sn)); // 返回开发者选项开关状态 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.UPLOAD_APK_LIST) 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 = "上传应用图标") @PostMapping("/upload_app_icon") @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPLOAD_APP_ICON) public Result uploadAppIcon( @RequestHeader(value = "X-Device-SN") String sn, @RequestParam String packageName, @RequestParam(required = false) String appName, @RequestPart(value = "file") MultipartFile file ) { try { if (sn == null || sn.trim().isEmpty()) { return Result.failed("设备序列号不能为空"); } if (packageName == null || packageName.trim().isEmpty()) { return Result.failed("应用包名不能为空"); } String iconUrl = appIconService.uploadIcon(packageName, appName, file, "device"); return Result.success(iconUrl); } catch (BusinessException e) { // 业务异常(如图标已锁定)需保留原始业务码,供设备端识别为终态、不再重试; // 不可被下方 catch(Exception) 压平成通用系统错误码 logger.info("uploadAppIcon rejected, sn: {}, packageName: {}, reason: {}", sn, packageName, e.getMessage()); throw e; } catch (Exception e) { logger.error("uploadAppIcon error, sn: {}", sn, e); return Result.failed("上传应用图标失败: " + e.getMessage()); } } }