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}