Files
youlai-boot/src/main/java/com/youlai/boot/device/controller/MobileController.java
TongTongStudio 9bc37286b7 feat(device): 新增远程拍照功能
- 新增设备端与家属端远程拍照接口
- 新增截图实体字段(原始文件名、MIME类型、扩展名)
- 补充截图上传信息记录
- 提取设备操作指令常量
2026-08-21 17:12:41 +08:00

554 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, Object> 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<String, Object> registerDevice(@RequestParam() String sn) {
// 生成设备密钥
String deviceSecret = IdUtil.fastSimpleUUID();
// 存储到Redis可根据需要设置过期时间
redisTemplate.opsForValue().set(DEVICE_SECRET_PREFIX + sn, deviceSecret, 365, TimeUnit.DAYS);
Map<String, Object> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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);
// 保存原始文件名,便于追溯/调试
screenshotInfo.setOriginName(safeBaseName);
// 保存MIME类型与扩展名便于前端筛选/预览
screenshotInfo.setMimeType(file.getContentType());
screenshotInfo.setFileExtension(fileExtension);
// 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<Void> 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<DeveloperOptionsVO> 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<Void> uploadInstallApks(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody List<ApkInstallInfoReq> 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<String> 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());
}
}
}