diff --git a/sql/app_icon.sql b/sql/app_icon.sql
index d16f4bc9..c2b6c4a5 100644
--- a/sql/app_icon.sql
+++ b/sql/app_icon.sql
@@ -11,7 +11,8 @@
-- 约定:
-- - 同一 package_name 下 status=1(当前生效)的记录有且仅有一条
-- - 历史版本(status=0)仅用于展示与选取锁定,不物理删除
--- - 图标文件按 {md5}.{ext} 内容寻址存储,访问路径由后端拼接
+-- - 图标文件按 {md5}.{ext} 内容寻址存储,访问路径由后端拼接为 /static/app_icon/{md5}.{ext}
+-- (该前缀与 WebMvcConfig 注册的静态资源映射一致;内容变更必然导致文件名变更,故可长缓存)
--
-- 部署规范:不直连库,由 DBA 执行;先 DROP 再 CREATE 确保结构与实体完全一致
-- (DROP 会清空历史图标数据,可由设备端重新上报补齐)。
@@ -23,7 +24,8 @@ CREATE TABLE `app_icon`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`package_name` VARCHAR(255) NOT NULL COMMENT '应用包名(全局唯一维度,同包名多版本共存)',
- `icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(相对路径,如 /api/v1/device/app_icon/{md5}.png)',
+ `app_name` VARCHAR(128) DEFAULT NULL COMMENT '应用名称(上报时的展示名,可为空;新版本上报时刷新)',
+ `icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(相对路径,如 /static/app_icon/{md5}.png)',
`icon_md5` VARCHAR(64) DEFAULT NULL COMMENT '图标文件MD5(内容指纹,用于去重与换图标检测)',
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同包名内自增,从1开始)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1当前生效 0历史版本)',
diff --git a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java
index 76dd024f..3791c00c 100644
--- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java
+++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java
@@ -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 目录
+ *
+ * 使用独立的 /static 前缀而非 /api/v1/device/**,避免与业务 Controller
+ * (DeviceController、DeviceOpsController)的 @RequestMapping 命名空间重叠:
+ * Controller 映射优先级高于静态资源,一旦后续新增同名路径接口会静默劫持图标请求。
+ *
+ * 图标文件名为内容 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());
}
}
diff --git a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java
index 32949a35..3b16036c 100644
--- a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java
+++ b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java
@@ -51,6 +51,7 @@ public enum ActionTypeEnum implements IBaseEnum {
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, "解锁应用图标"),
diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java
index 31169976..3b9fa998 100644
--- a/src/main/java/com/youlai/boot/common/result/ResultCode.java
+++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java
@@ -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", "设备标识不能为空"),
diff --git a/src/main/java/com/youlai/boot/device/controller/AppIconController.java b/src/main/java/com/youlai/boot/device/controller/AppIconController.java
index 9831ac2a..5b0c8aaa 100644
--- a/src/main/java/com/youlai/boot/device/controller/AppIconController.java
+++ b/src/main/java/com/youlai/boot/device/controller/AppIconController.java
@@ -71,8 +71,9 @@ public class AppIconController {
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.UPLOAD_APP_ICON)
public Result 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"));
}
}
diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
index c7976dcf..6ce0fee1 100644
--- a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
+++ b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
@@ -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);
+ }
}
diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java
index b4098b63..b2c9f84f 100644
--- a/src/main/java/com/youlai/boot/device/controller/MobileController.java
+++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java
@@ -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 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());
diff --git a/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java b/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java
index b407c835..07827d62 100644
--- a/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java
+++ b/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java
@@ -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;
diff --git a/src/main/java/com/youlai/boot/device/model/vo/AppIconVO.java b/src/main/java/com/youlai/boot/device/model/vo/AppIconVO.java
index 2b4f1204..c77717d2 100644
--- a/src/main/java/com/youlai/boot/device/model/vo/AppIconVO.java
+++ b/src/main/java/com/youlai/boot/device/model/vo/AppIconVO.java
@@ -26,6 +26,9 @@ public class AppIconVO implements Serializable {
@Schema(description = "应用包名")
private String packageName;
+ @Schema(description = "应用名称")
+ private String appName;
+
@Schema(description = "图标访问地址")
private String iconUrl;
diff --git a/src/main/java/com/youlai/boot/device/service/AppIconService.java b/src/main/java/com/youlai/boot/device/service/AppIconService.java
index b111aded..6678998a 100644
--- a/src/main/java/com/youlai/boot/device/service/AppIconService.java
+++ b/src/main/java/com/youlai/boot/device/service/AppIconService.java
@@ -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);
/**
* 批量查询多个应用当前生效图标地址
diff --git a/src/main/java/com/youlai/boot/device/service/DeviceService.java b/src/main/java/com/youlai/boot/device/service/DeviceService.java
index e445b32d..9ff508d5 100644
--- a/src/main/java/com/youlai/boot/device/service/DeviceService.java
+++ b/src/main/java/com/youlai/boot/device/service/DeviceService.java
@@ -130,6 +130,15 @@ public interface DeviceService extends IService {
*/
boolean uninstallApp(String sn, String packageName);
+ /**
+ * 停止应用(强制停止运行中的进程)
+ *
+ * @param sn 设备序列号
+ * @param packageName 应用包名
+ * @return 是否推送成功
+ */
+ boolean stopApp(String sn, String packageName);
+
/**
* 新增开发者选项配置
*
diff --git a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java
index fa6475d2..0c42f7b2 100644
--- a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java
+++ b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java
@@ -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 impl
/** 允许上传的图标格式 */
private static final Set 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 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 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 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 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 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 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) {
diff --git a/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java
index 4fc28b84..45d8256b 100644
--- a/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java
+++ b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java
@@ -350,6 +350,22 @@ public class DeviceServiceImpl extends ServiceImpl 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 {
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 4f80a66e..1d0dfd4a 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -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}
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 97aaa98c..4c81dcaf 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -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}