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:
TongTongStudio
2026-08-05 21:52:37 +08:00
parent 157e708a5b
commit 5cc08a73ff
15 changed files with 114 additions and 15 deletions

View File

@@ -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());
}
}

View File

@@ -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, "解锁应用图标"),

View File

@@ -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", "设备标识不能为空"),

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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());

View File

@@ -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;

View File

@@ -26,6 +26,9 @@ public class AppIconVO implements Serializable {
@Schema(description = "应用包名")
private String packageName;
@Schema(description = "应用名称")
private String appName;
@Schema(description = "图标访问地址")
private String iconUrl;

View File

@@ -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);
/**
* 批量查询多个应用当前生效图标地址

View File

@@ -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);
/**
* 新增开发者选项配置
*

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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}