feat(client): 新增家属端设备查询与端隔离鉴权
- 新增 ClientController,支持已绑定设备的信息、定位、应用与截图查询 - JWT 增加 clientType 标识,实现 admin/client 端鉴权隔离 - client 端支持独立配置令牌有效期,并在刷新时按端解析 TTL - 截图列表仅返回最近 20 条并规范化访问 URL 前缀
This commit is contained in:
@@ -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;
|
||||
|
||||
/**
|
||||
* 客户端(家属端)控制层
|
||||
*
|
||||
* <p>提供家属端获取已绑定设备的信息、定位、应用安装列表等能力。
|
||||
* 所有接口均需要先校验当前登录的 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。
|
||||
* <p>
|
||||
* 绑定关系:设备表 {@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<SnDeviceInfo>().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<DeviceSystemInfoVO> 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<DeviceLocationVO> 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<List<DeviceApkInfoVO>> getApks(
|
||||
@Parameter(description = "设备序列号") @RequestParam("sn") String sn
|
||||
) {
|
||||
try {
|
||||
checkBound(sn);
|
||||
List<DeviceApkInfoVO> 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<List<ScreenshotVO>> uploadScreenshot(
|
||||
@RequestParam(value = "sn", required = false) String sn
|
||||
) {
|
||||
try {
|
||||
if (!StringUtils.hasText(sn)) {
|
||||
return Result.failed("设备序列号不能为空");
|
||||
}
|
||||
List<ScreenshotVO> 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<List<DeviceBriefVO>> getMyDevices() {
|
||||
try {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
ClientUser clientUser = clientUserService.getById(userId);
|
||||
if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) {
|
||||
return Result.failed("当前用户未绑定手机号,无法查询设备");
|
||||
}
|
||||
|
||||
List<SnDeviceInfo> devices = deviceService.list(
|
||||
new LambdaQueryWrapper<SnDeviceInfo>()
|
||||
.eq(SnDeviceInfo::getSnMobile, clientUser.getMobile())
|
||||
.orderByDesc(SnDeviceInfo::getCreateTime)
|
||||
);
|
||||
|
||||
List<DeviceBriefVO> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 客户端(家属端)设备操作控制层
|
||||
*
|
||||
* <p>将后台管理端 {@code DeviceOpsController} 的设备操作能力移植到客户端:
|
||||
* 家属端用户登录后可对已绑定设备执行刷新、截图、重启、关机、定位、恢复、
|
||||
* 开发者模式、应用操作以及截图管理等能力,路径统一挂在 {@code /api/v1/client/sn/ops} 下。
|
||||
*
|
||||
* <p>与 {@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。
|
||||
* <p>
|
||||
* 绑定关系:设备表 {@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<SnDeviceInfo>().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<DevicePageVO> 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<DeviceLocationVO> 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<List<ScreenshotVO>> listScreenshots(
|
||||
@Parameter(description = "设备序列号") @RequestParam("sn") String sn
|
||||
) {
|
||||
try {
|
||||
checkBound(sn);
|
||||
List<ScreenshotVO> 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<Boolean> 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<Integer> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 + "/";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.youlai.boot.common.constant;
|
||||
|
||||
/**
|
||||
* 客户端类型常量
|
||||
* <p>
|
||||
* 用于在 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";
|
||||
|
||||
}
|
||||
@@ -45,4 +45,12 @@ public interface JwtClaimConstants {
|
||||
*/
|
||||
String TOKEN_VERSION = "tokenVersion";
|
||||
|
||||
/**
|
||||
* 客户端类型(clientType)
|
||||
* <p>
|
||||
* 标识令牌签发端:管理后台为 admin,家属客户端为 client。
|
||||
* 用于实现端与端之间的鉴权隔离(见 {@link com.youlai.boot.common.constant.ClientTypeConstants})。
|
||||
*/
|
||||
String CLIENT_TYPE = "clientType";
|
||||
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -74,6 +74,22 @@ public class SecurityProperties {
|
||||
@Min(-1)
|
||||
private Integer refreshTokenTimeToLive = 604800;
|
||||
|
||||
/**
|
||||
* 家属客户端访问令牌有效期(单位:秒)
|
||||
* <p>为 client 端单独配置,与管理后台(sys_user)区分;未配置时回退到 {@link #accessTokenTimeToLive}。</p>
|
||||
* <p>-1 表示永不过期</p>
|
||||
*/
|
||||
@Min(-1)
|
||||
private Integer clientAccessTokenTimeToLive = -1;
|
||||
|
||||
/**
|
||||
* 家属客户端刷新令牌有效期(单位:秒)
|
||||
* <p>为 client 端单独配置,与管理后台(sys_user)区分;未配置时回退到 {@link #refreshTokenTimeToLive}。</p>
|
||||
* <p>-1 表示永不过期</p>
|
||||
*/
|
||||
@Min(-1)
|
||||
private Integer clientRefreshTokenTimeToLive = -1;
|
||||
|
||||
/**
|
||||
* JWT 配置项
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,16 +28,30 @@ public class ScreenshotServiceImpl extends ServiceImpl<ScreenshotMapper, SnScree
|
||||
|
||||
@Override
|
||||
public List<ScreenshotVO> listBySn(String sn) {
|
||||
// 按 upload_time 倒序仅取最近 20 张,避免截图量大时返回全量影响性能与首页加载。
|
||||
List<SnScreenshot> list = this.list(
|
||||
new LambdaQueryWrapper<SnScreenshot>()
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 家属客户端接口访问授权管理器。
|
||||
* <p>
|
||||
* 仅允许携带 client 端令牌(clientType=client)访问 /api/v1/client/**,
|
||||
* 管理后台(admin)令牌即使有效也无法越权访问客户端接口,实现端与端隔离。
|
||||
*
|
||||
* @author TongTong Studio
|
||||
* @since 2026/08/18
|
||||
*/
|
||||
public class ClientAccessAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
|
||||
|
||||
@Override
|
||||
public AuthorizationDecision authorize(Supplier<? extends Authentication> 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);
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,13 @@ public class SecurityUser {
|
||||
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
|
||||
*/
|
||||
private List<RoleDataScope> dataScopes;
|
||||
|
||||
/**
|
||||
* 客户端类型(clientType)
|
||||
* <p>
|
||||
* 标识该用户所属端:管理后台为 admin、家属客户端为 client。
|
||||
* 签发令牌时写入 JWT claim,用于端与端之间的鉴权隔离。
|
||||
* 为空时由 {@code JwtTokenManager} 兜底为 admin。
|
||||
*/
|
||||
private String clientType;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ public class SecurityUserDetails implements UserDetails {
|
||||
*/
|
||||
private Set<String> roles;
|
||||
|
||||
/**
|
||||
* 客户端类型(clientType)
|
||||
* <p>
|
||||
* 标识令牌签发端:管理后台为 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 端用户本地认证)
|
||||
* <p>显式标记 clientType = client,与管理后台(admin)签发的令牌在 JWT 层面隔离。</p>
|
||||
*/
|
||||
private AuthenticationToken generateToken(SecurityUser securityUser) {
|
||||
// 端标识:家属客户端
|
||||
securityUser.setClientType(ClientTypeConstants.CLIENT);
|
||||
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user