feat(device): 重构应用图标静态资源路径并新增停止应用功能
- 将图标访问路径从 /api/v1/device/app_icon 迁移至 /static/app_icon,避免与业务 Controller 命名空间冲突,并添加 immutable 长缓存策略 - 新增 STOP_APP(39) 操作类型及 DeviceOpsController.stopApp 接口,支持强制停止应用 - 图标上传接口增加 appName 参数,支持应用名称上报与刷新,并做长度截断保护 - 图标锁定场景改用 BusinessException(APP_ICON_LOCKED) 替代 RuntimeException,设备端可据此识别终态不再重试 - 新增 /static/** 路径到安全白名单,允许 img 标签无 token 访问图标资源 - 同步更新数据库表结构注释及实体/VO 字段
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package com.youlai.boot.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Web MVC 配置
|
||||
*
|
||||
@@ -15,12 +18,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
/**
|
||||
* 应用图标静态资源映射:
|
||||
* /api/v1/device/app_icon/{sn}/{fileName} -> 磁盘 apkIcon 目录
|
||||
* /static/app_icon/{md5}.{ext} -> 磁盘 apkIcon 目录
|
||||
* <p>
|
||||
* 使用独立的 /static 前缀而非 /api/v1/device/**,避免与业务 Controller
|
||||
* (DeviceController、DeviceOpsController)的 @RequestMapping 命名空间重叠:
|
||||
* Controller 映射优先级高于静态资源,一旦后续新增同名路径接口会静默劫持图标请求。
|
||||
* <p>
|
||||
* 图标文件名为内容 MD5(内容寻址),内容变更必然导致 URL 变更,
|
||||
* 因此可安全使用 immutable 长缓存,浏览器无需回源校验。
|
||||
*/
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String location = "file:" + FilePath.getApkIconPath().replace("\\", "/");
|
||||
registry.addResourceHandler("/api/v1/device/app_icon/**")
|
||||
.addResourceLocations(location);
|
||||
registry.addResourceHandler("/static/app_icon/**")
|
||||
.addResourceLocations(location)
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ public enum ActionTypeEnum implements IBaseEnum<Integer> {
|
||||
LAUNCH_APP(33, "打开应用"),
|
||||
CLEAR_APP_DATA(34, "清除应用数据"),
|
||||
UNINSTALL_APP(35, "卸载应用"),
|
||||
STOP_APP(39, "停止应用"),
|
||||
UPLOAD_APP_ICON(36, "上传应用图标"),
|
||||
LOCK_APP_ICON(37, "锁定应用图标"),
|
||||
UNLOCK_APP_ICON(38, "解锁应用图标"),
|
||||
|
||||
@@ -104,6 +104,11 @@ public enum ResultCode implements IResultCode, Serializable {
|
||||
/** A07xx:文件处理异常 */
|
||||
UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"),
|
||||
DELETE_FILE_EXCEPTION("A0710", "删除文件异常"),
|
||||
/**
|
||||
* A0720:图标已锁定,属于「业务上明确拒绝」而非失败。
|
||||
* 设备端收到该码应视为终态,记录去重版本后不再重试上传。
|
||||
*/
|
||||
APP_ICON_LOCKED("A0720", "图标已锁定,不可修改,请先解锁"),
|
||||
|
||||
/** A08xx:移动设备认证异常 */
|
||||
MOBILE_DEVICE_ID_REQUIRED("A0801", "设备标识不能为空"),
|
||||
|
||||
@@ -71,8 +71,9 @@ public class AppIconController {
|
||||
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.UPLOAD_APP_ICON)
|
||||
public Result<String> upload(
|
||||
@Parameter(description = "应用包名") @PathVariable String packageName,
|
||||
@Parameter(description = "应用名称(可选,不传则沿用已有名称)") @RequestParam(required = false) String appName,
|
||||
@Parameter(description = "图标文件(jpg/jpeg/png/webp)") @RequestPart(value = "file") MultipartFile file
|
||||
) {
|
||||
return Result.success(appIconService.uploadIcon(packageName, file, "admin"));
|
||||
return Result.success(appIconService.uploadIcon(packageName, appName, file, "admin"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,4 +154,12 @@ public class DeviceOpsController {
|
||||
boolean result = deviceService.uninstallApp(sn, form.getPackageName());
|
||||
return Result.judge(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "停止应用")
|
||||
@PostMapping("/{sn}/app/stop")
|
||||
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.STOP_APP)
|
||||
public Result<?> stopApp(@PathVariable String sn, @Valid @RequestBody AppActionForm form) {
|
||||
boolean result = deviceService.stopApp(sn, form.getPackageName());
|
||||
return Result.judge(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -489,6 +490,7 @@ public class MobileController {
|
||||
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 {
|
||||
@@ -498,8 +500,13 @@ public class MobileController {
|
||||
if (packageName == null || packageName.trim().isEmpty()) {
|
||||
return Result.failed("应用包名不能为空");
|
||||
}
|
||||
String iconUrl = appIconService.uploadIcon(packageName, file, "device");
|
||||
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());
|
||||
|
||||
@@ -26,7 +26,12 @@ public class AppIcon extends BaseEntity {
|
||||
private String packageName;
|
||||
|
||||
/**
|
||||
* 图标访问地址(相对路径,如 /api/v1/device/app_icon/{md5}.png)
|
||||
* 应用名称(上报时的展示名,可为空;新版本上报时会刷新)
|
||||
*/
|
||||
private String appName;
|
||||
|
||||
/**
|
||||
* 图标访问地址(相对路径,如 /static/app_icon/{md5}.png)
|
||||
*/
|
||||
private String iconUrl;
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ public class AppIconVO implements Serializable {
|
||||
@Schema(description = "应用包名")
|
||||
private String packageName;
|
||||
|
||||
@Schema(description = "应用名称")
|
||||
private String appName;
|
||||
|
||||
@Schema(description = "图标访问地址")
|
||||
private String iconUrl;
|
||||
|
||||
|
||||
@@ -52,11 +52,12 @@ public interface AppIconService {
|
||||
* 上传应用图标(全局库:同包名未锁定时新版本替换当前,锁定则拒绝)
|
||||
*
|
||||
* @param packageName 应用包名
|
||||
* @param appName 应用名称(可为空;非空时会刷新已有记录的应用名)
|
||||
* @param file 图标文件(jpg/jpeg/png/webp)
|
||||
* @param source 来源(device/admin)
|
||||
* @return 图标访问地址
|
||||
*/
|
||||
String uploadIcon(String packageName, MultipartFile file, String source);
|
||||
String uploadIcon(String packageName, String appName, MultipartFile file, String source);
|
||||
|
||||
/**
|
||||
* 批量查询多个应用当前生效图标地址
|
||||
|
||||
@@ -130,6 +130,15 @@ public interface DeviceService extends IService<SnDeviceInfo> {
|
||||
*/
|
||||
boolean uninstallApp(String sn, String packageName);
|
||||
|
||||
/**
|
||||
* 停止应用(强制停止运行中的进程)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @param packageName 应用包名
|
||||
* @return 是否推送成功
|
||||
*/
|
||||
boolean stopApp(String sn, String packageName);
|
||||
|
||||
/**
|
||||
* 新增开发者选项配置
|
||||
*
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.youlai.boot.device.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.youlai.boot.common.config.FilePath;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.util.HashUtils;
|
||||
import com.youlai.boot.device.mapper.AppIconMapper;
|
||||
import com.youlai.boot.device.model.entity.AppIcon;
|
||||
@@ -15,6 +17,7 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
@@ -49,6 +52,15 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
/** 允许上传的图标格式 */
|
||||
private static final Set<String> ICON_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp");
|
||||
|
||||
/** 应用名称最大长度,需与 app_icon.app_name 字段长度一致 */
|
||||
private static final int APP_NAME_MAX_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* 图标访问地址前缀,需与 {@link com.youlai.boot.common.config.WebMvcConfig} 中
|
||||
* 注册的静态资源路径保持一致,修改时必须同步
|
||||
*/
|
||||
private static final String ICON_URL_PREFIX = "/static/app_icon/";
|
||||
|
||||
@Override
|
||||
public AppIconVO getCurrentIcon(String packageName) {
|
||||
AppIcon entity = this.lambdaQuery()
|
||||
@@ -114,10 +126,16 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
}
|
||||
|
||||
@Override
|
||||
public String uploadIcon(String packageName, MultipartFile file, String source) {
|
||||
public String uploadIcon(String packageName, String appName, MultipartFile file, String source) {
|
||||
Assert.isTrue(file != null && !file.isEmpty(), "上传文件不能为空");
|
||||
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
|
||||
|
||||
// 应用名可能来自设备端任意上报,做长度截断防止超出字段长度导致插入失败
|
||||
String safeAppName = StringUtils.hasText(appName) ? appName.trim() : null;
|
||||
if (safeAppName != null && safeAppName.length() > APP_NAME_MAX_LENGTH) {
|
||||
safeAppName = safeAppName.substring(0, APP_NAME_MAX_LENGTH);
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
|
||||
Assert.isTrue(extension != null && ICON_EXTENSIONS.contains(extension.toLowerCase()),
|
||||
"仅支持 jpg/jpeg/png/webp 格式图标");
|
||||
@@ -133,14 +151,21 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
|
||||
AppIcon current = getActiveEntity(packageName);
|
||||
|
||||
// 内容未变化:直接返回现有图标地址
|
||||
// 内容未变化:不产生新版本,但应用名可能已变更(如应用改名),需同步刷新
|
||||
if (current != null && md5.equals(current.getIconMd5())) {
|
||||
if (safeAppName != null && !safeAppName.equals(current.getAppName())) {
|
||||
current.setAppName(safeAppName);
|
||||
current.setUpdateBy(SecurityUtils.getUserId());
|
||||
this.updateById(current);
|
||||
}
|
||||
return current.getIconUrl();
|
||||
}
|
||||
|
||||
// 已锁定:拒绝覆盖(规避三方 App 换图标带广告)
|
||||
// 已锁定:拒绝覆盖图标(规避三方 App 换图标带广告)
|
||||
// 注意:此处不顺带更新应用名,避免后续若加事务时因抛异常被整体回滚
|
||||
// 抛带业务码的异常,便于设备端识别该场景为终态、不再重复上传
|
||||
if (current != null && current.getLocked() == LOCKED) {
|
||||
throw new RuntimeException("图标已锁定,不可修改,请先解锁");
|
||||
throw new BusinessException(ResultCode.APP_ICON_LOCKED);
|
||||
}
|
||||
|
||||
// 保存文件(内容寻址 {md5}.{ext},文件已存在则复用)
|
||||
@@ -155,6 +180,8 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
|
||||
AppIcon next = new AppIcon();
|
||||
next.setPackageName(packageName);
|
||||
// 本次未上报应用名时,沿用上一版本的名称,避免历史信息丢失
|
||||
next.setAppName(safeAppName != null ? safeAppName : current.getAppName());
|
||||
next.setIconUrl(iconUrl);
|
||||
next.setIconMd5(md5);
|
||||
next.setVersion(current.getVersion() + 1);
|
||||
@@ -167,6 +194,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
} else {
|
||||
AppIcon icon = new AppIcon();
|
||||
icon.setPackageName(packageName);
|
||||
icon.setAppName(safeAppName);
|
||||
icon.setIconUrl(iconUrl);
|
||||
icon.setIconMd5(md5);
|
||||
icon.setVersion(1);
|
||||
@@ -221,7 +249,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
String fileName = md5 + "." + extension;
|
||||
File destFile = new File(iconDir, fileName);
|
||||
if (destFile.exists()) {
|
||||
return "/api/v1/device/app_icon/" + fileName;
|
||||
return ICON_URL_PREFIX + fileName;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -230,7 +258,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
|
||||
log.error("保存图标文件失败, fileName: {}", fileName, e);
|
||||
throw new RuntimeException("保存应用图标失败");
|
||||
}
|
||||
return "/api/v1/device/app_icon/" + fileName;
|
||||
return ICON_URL_PREFIX + fileName;
|
||||
}
|
||||
|
||||
private AppIconVO toVo(AppIcon entity) {
|
||||
|
||||
@@ -350,6 +350,22 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean stopApp(String sn, String packageName) {
|
||||
PushSendParam pushSendParam = getAppPushSendParam(sn, "11", "appStop", "stop", packageName);
|
||||
try {
|
||||
PushSendResult result = pushApi.send(pushSendParam);
|
||||
log.info("send success:{}", result);
|
||||
return true;
|
||||
} catch (ApiErrorException e) {
|
||||
int httpStatus = e.getStats();
|
||||
int errorCode = e.getApiError().getError().getCode();
|
||||
String errorMessage = e.getApiError().getError().getMessage();
|
||||
log.error("stopApp send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean addDeveloperConfig(String sn, Integer developerOptions) {
|
||||
try {
|
||||
|
||||
@@ -109,6 +109,7 @@ security:
|
||||
- /api/v1/logs/** # 日志接口(访问日志列表)
|
||||
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
|
||||
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
|
||||
- /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举)
|
||||
# 非安全端点路径,完全绕过 Spring Security 的过滤器
|
||||
unsecured-urls:
|
||||
- ${springdoc.swagger-ui.path}
|
||||
|
||||
@@ -103,6 +103,7 @@ security:
|
||||
- /api/v1/logs/** # 日志接口(访问日志列表)
|
||||
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
|
||||
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
|
||||
- /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举)
|
||||
# 非安全端点路径,完全绕过 Spring Security 的过滤器
|
||||
unsecured-urls:
|
||||
- ${springdoc.swagger-ui.path}
|
||||
|
||||
Reference in New Issue
Block a user