diff --git a/src/main/java/com/youlai/boot/client/controller/ClientController.java b/src/main/java/com/youlai/boot/client/controller/ClientController.java new file mode 100644 index 00000000..4c236975 --- /dev/null +++ b/src/main/java/com/youlai/boot/client/controller/ClientController.java @@ -0,0 +1,201 @@ +package com.youlai.boot.client.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.client.model.entity.ClientUser; +import com.youlai.boot.client.service.ClientUserService; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.device.model.entity.SnDeviceInfo; +import com.youlai.boot.device.model.vo.DeviceApkInfoVO; +import com.youlai.boot.device.model.vo.DeviceBriefVO; +import com.youlai.boot.device.model.vo.DeviceLocationVO; +import com.youlai.boot.device.model.vo.DeviceSystemInfoVO; +import com.youlai.boot.device.model.vo.ScreenshotVO; +import com.youlai.boot.device.service.ApkInstallService; +import com.youlai.boot.device.service.DeviceService; +import com.youlai.boot.device.service.LocationService; +import com.youlai.boot.device.service.ScreenshotService; +import com.youlai.boot.framework.security.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 客户端(家属端)控制层 + * + *

提供家属端获取已绑定设备的信息、定位、应用安装列表等能力。 + * 所有接口均需要先校验当前登录的 client 用户是否绑定了传入的设备序列号(SN)。 + * + * @author TTSTD + * @since 2026/08/17 + */ +@Tag(name = "客户端-设备管理") +@RestController +@RequestMapping("/api/v1/client/sn") +@RequiredArgsConstructor +@Slf4j +public class ClientController { + + private final ClientUserService clientUserService; + private final DeviceService deviceService; + private final LocationService locationService; + private final ApkInstallService apkInstallService; + private final ScreenshotService screenshotService; + + /** + * 校验当前登录的 client 用户是否绑定了传入的设备 SN。 + *

+ * 绑定关系:设备表 {@code sys_sn.snMobile} 与 client 用户 {@code app_user.mobile} 一致即视为已绑定。 + * 校验未通过时抛出 {@link BusinessException},由全局异常处理器统一返回失败结果。 + * + * @param sn 设备序列号 + * @return 已绑定的设备记录 + */ + private SnDeviceInfo checkBound(String sn) { + if (!StringUtils.hasText(sn)) { + throw new BusinessException("设备序列号不能为空"); + } + + Long userId = SecurityUtils.getUserId(); + ClientUser clientUser = clientUserService.getById(userId); + if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) { + throw new BusinessException("当前用户未绑定手机号,无法校验设备"); + } + + SnDeviceInfo deviceInfo = deviceService.getOne( + new LambdaQueryWrapper().eq(SnDeviceInfo::getSerialno, sn) + ); + if (deviceInfo == null) { + throw new BusinessException("设备不存在"); + } + + if (!StringUtils.hasText(deviceInfo.getSnMobile()) + || !deviceInfo.getSnMobile().equals(clientUser.getMobile())) { + log.warn("client用户 {} 未绑定设备 sn: {}", userId, sn); + throw new BusinessException("当前用户未绑定该设备"); + } + + return deviceInfo; + } + + @Operation(summary = "获取已绑定设备基本信息") + @PreAuthorize("isAuthenticated()") + @GetMapping("/device-info") + public Result getDeviceInfo( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + DeviceSystemInfoVO vo = deviceService.getDeviceBasicInfo(sn); + return Result.success(vo); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getDeviceInfo error, sn: {}", sn, e); + return Result.failed("获取设备信息失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取已绑定设备最新定位信息") + @PreAuthorize("isAuthenticated()") + @GetMapping("/location") + public Result getLocation( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + DeviceLocationVO vo = locationService.getLatestBySn(sn); + return Result.success(vo); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getLocation error, sn: {}", sn, e); + return Result.failed("获取定位信息失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取已绑定设备已安装应用列表") + @PreAuthorize("isAuthenticated()") + @GetMapping("/apks") + public Result> getApks( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + List list = apkInstallService.getDeviceApkInfo(sn); + return Result.success(list); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getApks error, sn: {}", sn, e); + return Result.failed("获取应用列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取设备最近截图列表(家属端)") + @Parameter(name = "sn", description = "设备序列号", required = true) + @PreAuthorize("isAuthenticated()") + @GetMapping("/upload-screenshot") + public Result> uploadScreenshot( + @RequestParam(value = "sn", required = false) String sn + ) { + try { + if (!StringUtils.hasText(sn)) { + return Result.failed("设备序列号不能为空"); + } + List list = screenshotService.listBySn(sn); + return Result.success(list); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getScreenshots error, sn: {}", sn, e); + return Result.failed("获取截图失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取当前用户已绑定的设备列表") + @PreAuthorize("isAuthenticated()") + @GetMapping("/my-devices") + public Result> getMyDevices() { + try { + Long userId = SecurityUtils.getUserId(); + ClientUser clientUser = clientUserService.getById(userId); + if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) { + return Result.failed("当前用户未绑定手机号,无法查询设备"); + } + + List devices = deviceService.list( + new LambdaQueryWrapper() + .eq(SnDeviceInfo::getSnMobile, clientUser.getMobile()) + .orderByDesc(SnDeviceInfo::getCreateTime) + ); + + List list = devices.stream().map(d -> { + DeviceBriefVO vo = new DeviceBriefVO(); + vo.setSerialno(d.getSerialno()); + vo.setSnName(d.getSnName()); + vo.setSnModel(d.getSnModel()); + vo.setSnMobile(d.getSnMobile()); + vo.setStatus(d.getStatus()); + vo.setActivateTime(d.getActivateTime()); + return vo; + }).toList(); + + return Result.success(list); + } catch (Exception e) { + log.error("getMyDevices error", e); + return Result.failed("获取设备列表失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/youlai/boot/client/controller/ClientDeviceOpsController.java b/src/main/java/com/youlai/boot/client/controller/ClientDeviceOpsController.java new file mode 100644 index 00000000..b3b8e45c --- /dev/null +++ b/src/main/java/com/youlai/boot/client/controller/ClientDeviceOpsController.java @@ -0,0 +1,421 @@ +package com.youlai.boot.client.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.client.model.entity.ClientUser; +import com.youlai.boot.client.service.ClientUserService; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.device.model.entity.SnDeviceInfo; +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 com.youlai.boot.framework.security.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 客户端(家属端)设备操作控制层 + * + *

将后台管理端 {@code DeviceOpsController} 的设备操作能力移植到客户端: + * 家属端用户登录后可对已绑定设备执行刷新、截图、重启、关机、定位、恢复、 + * 开发者模式、应用操作以及截图管理等能力,路径统一挂在 {@code /api/v1/client/sn/ops} 下。 + * + *

与 {@link ClientController} 一致:所有接口先通过 {@link #checkBound(String)} + * 校验当前登录的 client 用户是否绑定传入的设备 SN,再执行操作。 + * SN 的新增/删除属于平台管理职责,不在家属端开放。 + * + * @author TTSTD + * @since 2026/08/18 + */ +@Tag(name = "客户端-设备操作") +@RestController +@RequestMapping("/api/v1/client/sn/ops") +@RequiredArgsConstructor +@Slf4j +public class ClientDeviceOpsController { + + private final ClientUserService clientUserService; + private final DeviceService deviceService; + private final LocationService locationService; + private final ScreenshotService screenshotService; + + /** + * 校验当前登录的 client 用户是否绑定了传入的设备 SN。 + *

+ * 绑定关系:设备表 {@code sys_sn.snMobile} 与 client 用户 {@code app_user.mobile} 一致即视为已绑定。 + * 校验未通过时抛出 {@link BusinessException},由调用方转换为失败结果返回。 + * + * @param sn 设备序列号 + * @return 已绑定的设备记录 + */ + private SnDeviceInfo checkBound(String sn) { + if (!StringUtils.hasText(sn)) { + throw new BusinessException("设备序列号不能为空"); + } + + Long userId = SecurityUtils.getUserId(); + ClientUser clientUser = clientUserService.getById(userId); + if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) { + throw new BusinessException("当前用户未绑定手机号,无法校验设备"); + } + + SnDeviceInfo deviceInfo = deviceService.getOne( + new LambdaQueryWrapper().eq(SnDeviceInfo::getSerialno, sn) + ); + if (deviceInfo == null) { + throw new BusinessException("设备不存在"); + } + + if (!StringUtils.hasText(deviceInfo.getSnMobile()) + || !deviceInfo.getSnMobile().equals(clientUser.getMobile())) { + log.warn("client用户 {} 未绑定设备 sn: {}", userId, sn); + throw new BusinessException("当前用户未绑定该设备"); + } + + return deviceInfo; + } + + @Operation(summary = "获取SN绑定激活信息(家属端)") + @PreAuthorize("isAuthenticated()") + @GetMapping("/info") + public Result getSnBindInfo( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + DevicePageVO detail = deviceService.getSnBindInfo(sn); + return Result.success(detail); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getSnBindInfo error, sn: {}", sn, e); + return Result.failed("获取设备绑定信息失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备刷新(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/refresh") + public Result deviceRefresh( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.deviceRefresh(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deviceRefresh error, sn: {}", sn, e); + return Result.failed("设备刷新失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备截图(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/screenshot") + public Result screenSnapshot( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.screenSnapshot(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("screenSnapshot error, sn: {}", sn, e); + return Result.failed("设备截图失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备重启(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/reboot") + public Result reboot( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.deviceReboot(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("reboot error, sn: {}", sn, e); + return Result.failed("设备重启失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备关机(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/shutdown") + public Result shutdown( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.deviceShutdown(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("shutdown error, sn: {}", sn, e); + return Result.failed("设备关机失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备定位(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/locate") + public Result deviceLocate( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.deviceLocate(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deviceLocate error, sn: {}", sn, e); + return Result.failed("设备定位失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取设备最新定位信息(家属端)", description = "返回设备最近一次上报的定位信息") + @PreAuthorize("isAuthenticated()") + @GetMapping("/location") + public Result getLocation( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + DeviceLocationVO location = locationService.getLatestBySn(sn); + return Result.success(location); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("getLocation error, sn: {}", sn, e); + return Result.failed("获取定位信息失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取设备截图列表(家属端)", description = "按上传时间倒序返回设备已上传的截图列表") + @PreAuthorize("isAuthenticated()") + @GetMapping("/screenshots") + public Result> listScreenshots( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + List list = screenshotService.listBySn(sn); + return Result.success(list); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("listScreenshots error, sn: {}", sn, e); + return Result.failed("获取截图列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除单张设备截图(家属端)", description = "删除指定截图(数据库记录与磁盘文件)") + @PreAuthorize("isAuthenticated()") + @DeleteMapping("/screenshots/{id}") + public Result deleteScreenshot( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn, + @Parameter(description = "截图ID", required = true) @PathVariable Long id + ) { + try { + checkBound(sn); + boolean result = screenshotService.deleteById(id); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deleteScreenshot error, sn: {}, id: {}", sn, id, e); + return Result.failed("删除截图失败: " + e.getMessage()); + } + } + + @Operation(summary = "清空设备全部截图(家属端)", description = "删除该设备的所有截图(数据库记录与磁盘文件)") + @PreAuthorize("isAuthenticated()") + @DeleteMapping("/screenshots") + public Result clearScreenshots( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + int count = screenshotService.clearBySn(sn); + return Result.success(count); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("clearScreenshots error, sn: {}", sn, e); + return Result.failed("清空截图失败: " + e.getMessage()); + } + } + + @Operation(summary = "设备重置(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/restore") + public Result deviceRestore( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.restore(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deviceRestore error, sn: {}", sn, e); + return Result.failed("设备重置失败: " + e.getMessage()); + } + } + + @Operation(summary = "开发者模式(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/developer") + public Result deviceDeveloper( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.setDeviceDeveloper(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deviceDeveloper error, sn: {}", sn, e); + return Result.failed("设置开发者模式失败: " + e.getMessage()); + } + } + + @Operation(summary = "新增开发者选项配置(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/developer/config") + public Result addDeveloperConfig( + @Parameter(description = "开发者选项配置") @Valid @RequestBody DeveloperForm developerForm + ) { + String sn = developerForm.getSn(); + try { + checkBound(sn); + boolean result = deviceService.addDeveloperConfig(sn, developerForm.getDeveloperOptions()); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("addDeveloperConfig error, sn: {}", sn, e); + return Result.failed("新增开发者选项配置失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除开发者选项配置(家属端)") + @PreAuthorize("isAuthenticated()") + @DeleteMapping("/developer/config") + public Result deleteDeveloperConfig( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + checkBound(sn); + boolean result = deviceService.deleteDeveloperConfig(sn); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("deleteDeveloperConfig error, sn: {}", sn, e); + return Result.failed("删除开发者选项配置失败: " + e.getMessage()); + } + } + + @Operation(summary = "打开应用(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/app/launch") + public Result launchApp( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn, + @Parameter(description = "应用操作表单") @Valid @RequestBody AppActionForm form + ) { + try { + checkBound(sn); + boolean result = deviceService.launchApp(sn, form.getPackageName()); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("launchApp error, sn: {}, packageName: {}", sn, form.getPackageName(), e); + return Result.failed("打开应用失败: " + e.getMessage()); + } + } + + @Operation(summary = "清除应用数据(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/app/clear_data") + public Result clearAppData( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn, + @Parameter(description = "应用操作表单") @Valid @RequestBody AppActionForm form + ) { + try { + checkBound(sn); + boolean result = deviceService.clearAppData(sn, form.getPackageName()); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("clearAppData error, sn: {}, packageName: {}", sn, form.getPackageName(), e); + return Result.failed("清除应用数据失败: " + e.getMessage()); + } + } + + @Operation(summary = "卸载应用(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/app/uninstall") + public Result uninstallApp( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn, + @Parameter(description = "应用操作表单") @Valid @RequestBody AppActionForm form + ) { + try { + checkBound(sn); + boolean result = deviceService.uninstallApp(sn, form.getPackageName()); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("uninstallApp error, sn: {}, packageName: {}", sn, form.getPackageName(), e); + return Result.failed("卸载应用失败: " + e.getMessage()); + } + } + + @Operation(summary = "停止应用(家属端)") + @PreAuthorize("isAuthenticated()") + @PostMapping("/app/stop") + public Result stopApp( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn, + @Parameter(description = "应用操作表单") @Valid @RequestBody AppActionForm form + ) { + try { + checkBound(sn); + boolean result = deviceService.stopApp(sn, form.getPackageName()); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("stopApp error, sn: {}, packageName: {}", sn, form.getPackageName(), e); + return Result.failed("停止应用失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/youlai/boot/common/config/FilePath.java b/src/main/java/com/youlai/boot/common/config/FilePath.java index 212e364f..c2972331 100644 --- a/src/main/java/com/youlai/boot/common/config/FilePath.java +++ b/src/main/java/com/youlai/boot/common/config/FilePath.java @@ -57,4 +57,18 @@ public class FilePath { public static String getCategoryIconPath() { return getRootPath() + File.separator + TABLET_PATH + File.separator + CATEGORY_ICON_PATH + File.separator; } + + /** + * 截图存储绝对目录 + */ + public static String getScreenshotDir() { + return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH; + } + + /** + * 截图相对访问路径(用于拼接存储到数据库的 filePath) + */ + public static String getScreenshotRelativePath() { + return TABLET_PATH + "/" + SCREENSHOT_PATH + "/"; + } } diff --git a/src/main/java/com/youlai/boot/common/constant/ClientTypeConstants.java b/src/main/java/com/youlai/boot/common/constant/ClientTypeConstants.java new file mode 100644 index 00000000..a5f3ff1e --- /dev/null +++ b/src/main/java/com/youlai/boot/common/constant/ClientTypeConstants.java @@ -0,0 +1,25 @@ +package com.youlai.boot.common.constant; + +/** + * 客户端类型常量 + *

+ * 用于在 JWT 中区分不同端签发的令牌,实现端与端之间的鉴权隔离。 + * 管理后台(sys_user)与家属客户端(app_user)使用同一套 TokenManager 签发, + * 通过本端标识区分令牌来源,避免跨端越权访问。 + * + * @author TongTong Studio + * @since 2026/08/18 + */ +public interface ClientTypeConstants { + + /** + * 管理后台(sys_user,RBAC 角色体系) + */ + String ADMIN = "admin"; + + /** + * 家属客户端(app_user) + */ + String CLIENT = "client"; + +} diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index 61be1d98..45fcf0b5 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -45,4 +45,12 @@ public interface JwtClaimConstants { */ String TOKEN_VERSION = "tokenVersion"; + /** + * 客户端类型(clientType) + *

+ * 标识令牌签发端:管理后台为 admin,家属客户端为 client。 + * 用于实现端与端之间的鉴权隔离(见 {@link com.youlai.boot.common.constant.ClientTypeConstants})。 + */ + String CLIENT_TYPE = "clientType"; + } diff --git a/src/main/java/com/youlai/boot/common/util/HashUtils.java b/src/main/java/com/youlai/boot/common/util/HashUtils.java index c3ed4c76..21a4a86e 100644 --- a/src/main/java/com/youlai/boot/common/util/HashUtils.java +++ b/src/main/java/com/youlai/boot/common/util/HashUtils.java @@ -102,6 +102,22 @@ public class HashUtils { return bytesToHex(hashBytes, true); } + /** + * 计算字节数组的 SHA256 哈希值 + * + * @param data 字节数组 + * @return SHA256 哈希值(小写十六进制字符串) + */ + public static String sha256(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(data); + return bytesToHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + public static String calculateSHA256(File file) throws NoSuchAlgorithmException, IOException { MessageDigest digest = MessageDigest.getInstance("SHA-256"); try (InputStream inputStream = new FileInputStream(file)) { diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 7283d818..12b8e401 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.ArrayUtil; import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter; import com.youlai.boot.auth.service.CaptchaService; import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.framework.security.authorization.ClientAccessAuthorizationManager; import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.service.SecurityUserDetailsService; @@ -71,7 +72,12 @@ public class SecurityConfig { // 移动设备专用接口路径(需要设备签名验证,但不需要用户登录) requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll(); + // open 认证接口(登录/注册/刷新/退出)免登录;其业务接口统一走下方 authenticated() 保护 requestMatcherRegistry.requestMatchers("/api/v1/open/**").permitAll(); + // client 家属端接口:与后台管理使用同一套 TokenManager 鉴权, + // 但仅允许携带 client 端令牌(clientType=client)访问,实现端与端隔离 + requestMatcherRegistry.requestMatchers("/api/v1/client/**") + .access(new ClientAccessAuthorizationManager()); // 其他所有请求需登录后访问 requestMatcherRegistry.anyRequest().authenticated(); } diff --git a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java index 5e4b3725..e52e0888 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -74,6 +74,22 @@ public class SecurityProperties { @Min(-1) private Integer refreshTokenTimeToLive = 604800; + /** + * 家属客户端访问令牌有效期(单位:秒) + *

为 client 端单独配置,与管理后台(sys_user)区分;未配置时回退到 {@link #accessTokenTimeToLive}。

+ *

-1 表示永不过期

+ */ + @Min(-1) + private Integer clientAccessTokenTimeToLive = -1; + + /** + * 家属客户端刷新令牌有效期(单位:秒) + *

为 client 端单独配置,与管理后台(sys_user)区分;未配置时回退到 {@link #refreshTokenTimeToLive}。

+ *

-1 表示永不过期

+ */ + @Min(-1) + private Integer clientRefreshTokenTimeToLive = -1; + /** * JWT 配置项 */ diff --git a/src/main/java/com/youlai/boot/device/model/vo/DeviceBriefVO.java b/src/main/java/com/youlai/boot/device/model/vo/DeviceBriefVO.java new file mode 100644 index 00000000..b99d4e50 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DeviceBriefVO.java @@ -0,0 +1,36 @@ +package com.youlai.boot.device.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 设备简要信息视图对象(家属端"我的设备"列表使用) + * + * @author TTSTD + * @since 2026/08/17 + */ +@Schema(description = "设备简要信息") +@Data +public class DeviceBriefVO implements Serializable { + + @Schema(description = "设备序列号") + private String serialno; + + @Schema(description = "设备名称") + private String snName; + + @Schema(description = "设备型号") + private String snModel; + + @Schema(description = "绑定手机号") + private String snMobile; + + @Schema(description = "状态(0-未激活 1-正常)") + private Integer status; + + @Schema(description = "激活时间") + private LocalDateTime activateTime; +} 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 89078e82..a5fee759 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 @@ -28,16 +28,30 @@ public class ScreenshotServiceImpl extends ServiceImpl listBySn(String sn) { + // 按 upload_time 倒序仅取最近 20 张,避免截图量大时返回全量影响性能与首页加载。 List list = this.list( new LambdaQueryWrapper() .eq(SnScreenshot::getSn, sn) .orderByDesc(SnScreenshot::getUploadTime) + .last("LIMIT 20") ); + // 规范化访问前缀,避免配置值含多余斜杠导致 URL 拼接异常。 + String prefix = accessUrlPrefix; + if (prefix == null || prefix.isEmpty()) { + prefix = "/static"; + } + if (!prefix.startsWith("/")) { + prefix = "/" + prefix; + } + while (prefix.endsWith("/")) { + prefix = prefix.substring(0, prefix.length() - 1); + } + final String base = prefix + "/screenshot/"; return list.stream().map(entity -> { ScreenshotVO vo = new ScreenshotVO(); BeanUtils.copyProperties(entity, vo); // 拼接可访问的静态资源 URL:/static/screenshot/{fileName} - vo.setUrl(accessUrlPrefix + "/screenshot/" + entity.getFileName()); + vo.setUrl(base + entity.getFileName()); return vo; }).collect(Collectors.toList()); } diff --git a/src/main/java/com/youlai/boot/framework/security/authorization/ClientAccessAuthorizationManager.java b/src/main/java/com/youlai/boot/framework/security/authorization/ClientAccessAuthorizationManager.java new file mode 100644 index 00000000..ecdf4e90 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/authorization/ClientAccessAuthorizationManager.java @@ -0,0 +1,39 @@ +package com.youlai.boot.framework.security.authorization; + +import com.youlai.boot.common.constant.ClientTypeConstants; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import java.util.function.Supplier; + +/** + * 家属客户端接口访问授权管理器。 + *

+ * 仅允许携带 client 端令牌(clientType=client)访问 /api/v1/client/**, + * 管理后台(admin)令牌即使有效也无法越权访问客户端接口,实现端与端隔离。 + * + * @author TongTong Studio + * @since 2026/08/18 + */ +public class ClientAccessAuthorizationManager implements AuthorizationManager { + + @Override + public AuthorizationDecision authorize(Supplier authenticationSupplier, + RequestAuthorizationContext object) { + Authentication authentication = authenticationSupplier.get(); + // 未认证 + if (authentication == null || !authentication.isAuthenticated()) { + return new AuthorizationDecision(false); + } + Object principal = authentication.getPrincipal(); + if (principal instanceof SecurityUserDetails userDetails) { + // 端标识为 client 才放行 + boolean isClient = ClientTypeConstants.CLIENT.equals(userDetails.getClientType()); + return new AuthorizationDecision(isClient); + } + return new AuthorizationDecision(false); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java index 96ee67ef..394897b5 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java @@ -63,4 +63,13 @@ public class SecurityUser { * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ private List dataScopes; + + /** + * 客户端类型(clientType) + *

+ * 标识该用户所属端:管理后台为 admin、家属客户端为 client。 + * 签发令牌时写入 JWT claim,用于端与端之间的鉴权隔离。 + * 为空时由 {@code JwtTokenManager} 兜底为 admin。 + */ + private String clientType; } diff --git a/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java index eeecd5f3..754b7ece 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java @@ -61,6 +61,14 @@ public class SecurityUserDetails implements UserDetails { */ private Set roles; + /** + * 客户端类型(clientType) + *

+ * 标识令牌签发端:管理后台为 admin、家属客户端为 client。 + * 从 JWT claim 解析或构造时赋值,用于端与端之间的鉴权隔离。 + */ + private String clientType; + /** * 构造函数:根据 {@link SecurityUser} 初始化。 */ @@ -72,6 +80,7 @@ public class SecurityUserDetails implements UserDetails { this.deptId = user.getDeptId(); this.dataScopes = user.getDataScopes(); this.roles = user.getRoles(); + this.clientType = user.getClientType(); } @Override diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java index 433322e5..54bf431e 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java @@ -9,6 +9,7 @@ import cn.hutool.json.JSONObject; import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWTPayload; import cn.hutool.jwt.JWTUtil; +import com.youlai.boot.common.constant.ClientTypeConstants; import com.youlai.boot.common.constant.JwtClaimConstants; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; @@ -24,6 +25,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; import java.util.*; import java.util.concurrent.TimeUnit; @@ -41,6 +43,7 @@ import java.util.stream.Collectors; * @since 2024/11/15 */ @ConditionalOnProperty(value = "security.session.type", havingValue = "jwt") +@Slf4j @Service public class JwtTokenManager implements TokenManager { @@ -56,8 +59,10 @@ public class JwtTokenManager implements TokenManager { @Override public AuthenticationToken generateToken(Authentication authentication) { - int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); - int refreshTokenTimeToLive = securityProperties.getSession().getRefreshTokenTimeToLive(); + SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal(); + + int accessTokenTimeToLive = resolveAccessTtl(userDetails); + int refreshTokenTimeToLive = resolveRefreshTtl(userDetails); String accessToken = generateToken(authentication, accessTokenTimeToLive); String refreshToken = generateToken(authentication, refreshTokenTimeToLive, true); @@ -70,6 +75,40 @@ public class JwtTokenManager implements TokenManager { .build(); } + /** + * 解析指定用户的访问令牌 TTL:client 端使用独立配置(未配置时回退全局), + * 其余端(管理后台)使用全局配置。 + */ + private int resolveAccessTtl(SecurityUserDetails userDetails) { + if (ClientTypeConstants.CLIENT.equals(userDetails.getClientType())) { + return resolveTtl( + securityProperties.getSession().getClientAccessTokenTimeToLive(), + securityProperties.getSession().getAccessTokenTimeToLive()); + } + return securityProperties.getSession().getAccessTokenTimeToLive(); + } + + /** + * 解析指定用户的刷新令牌 TTL:client 端使用独立配置(未配置时回退全局), + * 其余端(管理后台)使用全局配置。 + */ + private int resolveRefreshTtl(SecurityUserDetails userDetails) { + if (ClientTypeConstants.CLIENT.equals(userDetails.getClientType())) { + return resolveTtl( + securityProperties.getSession().getClientRefreshTokenTimeToLive(), + securityProperties.getSession().getRefreshTokenTimeToLive()); + } + return securityProperties.getSession().getRefreshTokenTimeToLive(); + } + + /** + * 解析端专属 TTL:若该端配置值有效(>= 0,含 -1 表示永不过期)则使用, + * 否则回退到全局配置值。 + */ + private int resolveTtl(int clientTtl, int defaultTtl) { + return clientTtl >= 0 ? clientTtl : defaultTtl; + } + @Override public Authentication parseToken(String token) { JWT jwt = JWTUtil.parseToken(token); @@ -99,6 +138,13 @@ public class JwtTokenManager implements TokenManager { userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); + // 客户端类型(端标识),无该 claim 时兜底为 admin + String clientType = payloads.getStr(JwtClaimConstants.CLIENT_TYPE); + if (StrUtil.isBlank(clientType)) { + clientType = ClientTypeConstants.ADMIN; + } + userDetails.setClientType(clientType); + // 角色编码(不带 ROLE_ 前缀) JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES); if (rolesArray != null && !rolesArray.isEmpty()) { @@ -122,16 +168,22 @@ public class JwtTokenManager implements TokenManager { } private boolean validateToken(String token, boolean validateRefreshToken) { - JWT jwt = JWTUtil.parseToken(token); - boolean isValid = jwt.setKey(secretKey).validate(0); + try { + JWT jwt = JWTUtil.parseToken(token); + boolean isValid = jwt.setKey(secretKey).validate(0); + + if (!isValid) { + log.warn("Token 签名/过期校验失败,可能原因:secretKey 不匹配或 token 已过期"); + return false; + } - if (isValid) { JSONObject payloads = jwt.getPayloads(); // 刷新令牌类型校验 String jti = payloads.getStr(JWTPayload.JWT_ID); if (validateRefreshToken) { boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); if (!isRefreshToken) { + log.warn("Token 校验失败:非刷新令牌,jti={}", jti); return false; } } @@ -146,16 +198,23 @@ public class JwtTokenManager implements TokenManager { int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0; if (tokenVersion == null || tokenVersion < currentVersion) { + log.warn("Token 校验失败:tokenVersion({}) < 当前版本({}),userId={},会话已失效,jti={}", + tokenVersion, currentVersion, userId, jti); return false; } } // jti 黑名单校验 if (isTokenRevoked(jti)) { + log.warn("Token 校验失败:jti({}) 已被撤销", jti); return false; } + return true; + } catch (Exception e) { + log.warn("Token 校验异常:{},token 前缀={}", e.getMessage(), + token != null && token.length() > 20 ? token.substring(0, 20) + "..." : token, e); + return false; } - return isValid; } @Override @@ -189,7 +248,9 @@ public class JwtTokenManager implements TokenManager { throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); } Authentication authentication = parseToken(refreshToken); - int accessTokenExpiration = securityProperties.getSession().getAccessTokenTimeToLive(); + // 刷新时按原令牌所属端解析访问令牌 TTL(client 端独立配置,管理后台全局配置) + SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal(); + int accessTokenExpiration = resolveAccessTtl(userDetails); String newAccessToken = generateToken(authentication, accessTokenExpiration); return AuthenticationToken.builder() .accessToken(newAccessToken) @@ -232,6 +293,14 @@ public class JwtTokenManager implements TokenManager { payload.put(JwtClaimConstants.ROLES, roles); } + // 客户端类型(端标识),用于端与端之间的鉴权隔离。 + // 为空时兜底为 admin(管理后台),保证既有 admin 令牌逻辑不受影响。 + String clientType = userDetails.getClientType(); + if (StrUtil.isBlank(clientType)) { + clientType = ClientTypeConstants.ADMIN; + } + payload.put(JwtClaimConstants.CLIENT_TYPE, clientType); + Long userId = userDetails.getUserId(); int tokenVersion = 0; if (userId != null) { diff --git a/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java b/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java index d1916bff..dec7f7a4 100644 --- a/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java @@ -10,6 +10,7 @@ import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.open.mapper.ClientUserMapper; import com.youlai.boot.open.service.OpenAuthService; import com.youlai.boot.client.service.ClientUserService; +import com.youlai.boot.common.constant.ClientTypeConstants; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.util.CodeGeneratorUtil; import com.youlai.boot.common.exception.BusinessException; @@ -245,8 +246,11 @@ public class OpenAuthServiceImpl implements OpenAuthService { /** * 根据 SecurityUser 构造 Authentication 并签发令牌(C 端用户本地认证) + *

显式标记 clientType = client,与管理后台(admin)签发的令牌在 JWT 层面隔离。

*/ private AuthenticationToken generateToken(SecurityUser securityUser) { + // 端标识:家属客户端 + securityUser.setClientType(ClientTypeConstants.CLIENT); SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e5348b8e..4c0d22b7 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -122,6 +122,9 @@ security: type: jwt # 会话方式 [jwt|redis-token] access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 + # 家属客户端(client)独立令牌有效期,与管理后台区分;未配置时回退到上方全局值 + client-access-token-time-to-live: 604800 # client 访问令牌 7 天 + client-refresh-token-time-to-live: 7776000 # client 刷新令牌 3 个月(按90天) jwt: secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fe41909e..728a5e51 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -116,6 +116,9 @@ security: type: jwt # 会话方式 [jwt|redis-token] access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 + # 家属客户端(client)独立令牌有效期,与管理后台区分;未配置时回退到上方全局值 + client-access-token-time-to-live: 604800 # client 访问令牌 7 天 + client-refresh-token-time-to-live: 7776000 # client 刷新令牌 3 个月(按90天) jwt: secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: