feat(device): 重构应用图标功能,新增停止应用操作

- 静态资源路径从 /api/v1/device/app_icon 迁移至 /static/app_icon,避免与 Controller 路径冲突,并添加 immutable 长缓存策略
- 数据库及实体新增 app_name 字段,支持图标上报时同步应用名称
- 图标锁定场景改用 BusinessException(APP_ICON_LOCKED) 明确业务拒绝码,设备端可识别为终态
- 新增 STOP_APP 操作类型及对应推送接口
- 安全配置放行 /static/** 静态资源路径
This commit is contained in:
2026-08-05 16:25:33 +08:00
committed by TongTongStudio
parent 749cf44754
commit 157e708a5b
18 changed files with 864 additions and 26 deletions

45
sql/app_icon.sql Normal file
View File

@@ -0,0 +1,45 @@
-- =============================================================================
-- 应用图标库表app_icon
--
-- 设计目标:
-- 1. 全局图标库同一应用包名package_name的所有设备共用图标按包名维度维护
-- 2. 历史版本同一包名可保存多个历史图标版本status 区分当前生效/历史),
-- 支持从历史版本中选取并锁定,规避三方 App 活动期换图标带广告等风险
-- 3. 锁定机制locked=1 的版本不可被设备端/管理端上传覆盖,需先解锁
-- 4. 内容指纹icon_md5 用于内容去重(同一图标不同包名复用同一文件)
--
-- 约定:
-- - 同一 package_name 下 status=1当前生效的记录有且仅有一条
-- - 历史版本status=0仅用于展示与选取锁定不物理删除
-- - 图标文件按 {md5}.{ext} 内容寻址存储,访问路径由后端拼接
--
-- 部署规范:不直连库,由 DBA 执行;先 DROP 再 CREATE 确保结构与实体完全一致
-- DROP 会清空历史图标数据,可由设备端重新上报补齐)。
-- =============================================================================
DROP TABLE IF EXISTS `app_icon`;
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',
`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历史版本',
`locked` TINYINT NOT NULL DEFAULT 0 COMMENT '是否锁定0未锁定 1已锁定锁定后不可上传覆盖',
`source` VARCHAR(20) NOT NULL DEFAULT 'device' COMMENT '图标来源device设备端上报 admin管理端上传',
`create_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` BIGINT DEFAULT NULL COMMENT '更新人ID',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0未删 1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_package_version` (`package_name`, `version`),
KEY `idx_package_status` (`package_name`, `status`),
KEY `idx_md5` (`icon_md5`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '应用图标库(含历史版本)';

View File

@@ -0,0 +1,26 @@
package com.youlai.boot.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
*
* @author TTSTD
* @since 2026-08-05
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 应用图标静态资源映射:
* /api/v1/device/app_icon/{sn}/{fileName} -> 磁盘 apkIcon 目录
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String location = "file:" + FilePath.getApkIconPath().replace("\\", "/");
registry.addResourceHandler("/api/v1/device/app_icon/**")
.addResourceLocations(location);
}
}

View File

@@ -40,6 +40,20 @@ public enum ActionTypeEnum implements IBaseEnum<Integer> {
LOCATE(22, "定位"),
RESTORE(23, "重置"),
DEVELOPER(24, "开发者选项"),
UPDATE_SYSTEM_INFO(25, "更新系统信息"),
UPDATE_OTHER_INFO(26, "更新其他信息"),
UPDATE_HARDWARE_INFO(27, "更新硬件信息"),
UPDATE_NETWORK_INFO(28, "更新网络信息"),
UPDATE_SECURITY_INFO(29, "更新安全信息"),
VIEW_DEVELOPER_OPTIONS(30, "查看开发者选项"),
UPLOAD_APK_LIST(31, "上传应用列表"),
VIEW_APK_LIST(32, "查看应用列表"),
LAUNCH_APP(33, "打开应用"),
CLEAR_APP_DATA(34, "清除应用数据"),
UNINSTALL_APP(35, "卸载应用"),
UPLOAD_APP_ICON(36, "上传应用图标"),
LOCK_APP_ICON(37, "锁定应用图标"),
UNLOCK_APP_ICON(38, "解锁应用图标"),
OTHER(99, "其他");
@EnumValue

View File

@@ -0,0 +1,78 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.device.model.vo.AppIconVO;
import com.youlai.boot.device.service.AppIconService;
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 org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 应用图标库控制层
*
* @author TTSTD
* @since 2026-08-05
*/
@Tag(name = "应用图标库")
@RestController
@RequestMapping("/api/v1/app-icons")
@RequiredArgsConstructor
public class AppIconController {
private final AppIconService appIconService;
@Operation(summary = "获取应用当前生效图标信息")
@GetMapping("/{packageName}")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.VIEW)
public Result<AppIconVO> getCurrentIcon(@PathVariable String packageName) {
return Result.success(appIconService.getCurrentIcon(packageName));
}
@Operation(summary = "获取应用图标历史版本列表(含当前,按版本倒序)")
@GetMapping("/{packageName}/history")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LIST)
public Result<List<AppIconVO>> getHistory(@PathVariable String packageName) {
return Result.success(appIconService.getHistory(packageName));
}
@Operation(summary = "选取历史版本并锁定")
@PostMapping("/{packageName}/lock")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LOCK_APP_ICON)
public Result<?> lock(
@Parameter(description = "应用包名") @PathVariable String packageName,
@Parameter(description = "选中图标记录ID") @RequestParam Long iconId
) {
return Result.judge(appIconService.lockIcon(packageName, iconId));
}
@Operation(summary = "解锁当前图标")
@PostMapping("/{packageName}/unlock")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.UNLOCK_APP_ICON)
public Result<?> unlock(@Parameter(description = "应用包名") @PathVariable String packageName) {
return Result.judge(appIconService.unlockIcon(packageName));
}
@Operation(summary = "上传应用图标(全局库)")
@PostMapping("/{packageName}/upload")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.UPLOAD_APP_ICON)
public Result<String> upload(
@Parameter(description = "应用包名") @PathVariable String packageName,
@Parameter(description = "图标文件jpg/jpeg/png/webp") @RequestPart(value = "file") MultipartFile file
) {
return Result.success(appIconService.uploadIcon(packageName, file, "admin"));
}
}

View File

@@ -1,15 +1,20 @@
package com.youlai.boot.device.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DeviceApkInfoVO;
import com.youlai.boot.device.model.vo.DeviceHardwareInfoVO;
import com.youlai.boot.device.model.vo.DeviceNetworkInfoVO;
import com.youlai.boot.device.model.vo.DeviceOtherInfoVO;
import com.youlai.boot.device.model.vo.DevicePageVO;
import com.youlai.boot.device.model.vo.DeviceSecurityInfoVO;
import com.youlai.boot.device.model.vo.DeviceSystemInfoVO;
import com.youlai.boot.device.service.ApkInstallService;
import com.youlai.boot.device.service.DeviceService;
import com.youlai.boot.device.service.HardwareInfoService;
import com.youlai.boot.device.service.NetworkInfoService;
@@ -26,6 +31,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 设备管理控制器
*
@@ -45,6 +52,7 @@ public class DeviceController {
private final NetworkInfoService networkInfoService;
private final SecurityInfoService securityInfoService;
private final OtherInfoService otherInfoService;
private final ApkInstallService apkInstallService;
@Operation(summary = "设备分页列表")
@GetMapping("/page")
@@ -102,4 +110,13 @@ public class DeviceController {
DeviceOtherInfoVO vo = deviceService.getDeviceOtherInfo(sn);
return Result.success(vo);
}
@Operation(summary = "设备已安装应用列表")
@GetMapping("/{sn}/apk_list")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.VIEW_APK_LIST)
public Result<List<DeviceApkInfoVO>> getApkList(
@Parameter(description = "设备序列号") @PathVariable("sn") String sn) {
return Result.success(apkInstallService.getDeviceApkInfo(sn));
}
}

View File

@@ -5,6 +5,7 @@ import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
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.DevicePageVO;
import com.youlai.boot.device.service.DeviceService;
@@ -129,4 +130,28 @@ public class DeviceOpsController {
boolean result = deviceService.deleteDeveloperConfig(sn);
return Result.judge(result);
}
@Operation(summary = "打开应用")
@PostMapping("/{sn}/app/launch")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LAUNCH_APP)
public Result<?> launchApp(@PathVariable String sn, @Valid @RequestBody AppActionForm form) {
boolean result = deviceService.launchApp(sn, form.getPackageName());
return Result.judge(result);
}
@Operation(summary = "清除应用数据")
@PostMapping("/{sn}/app/clear_data")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.CLEAR_APP_DATA)
public Result<?> clearAppData(@PathVariable String sn, @Valid @RequestBody AppActionForm form) {
boolean result = deviceService.clearAppData(sn, form.getPackageName());
return Result.judge(result);
}
@Operation(summary = "卸载应用")
@PostMapping("/{sn}/app/uninstall")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.UNINSTALL_APP)
public Result<?> uninstallApp(@PathVariable String sn, @Valid @RequestBody AppActionForm form) {
boolean result = deviceService.uninstallApp(sn, form.getPackageName());
return Result.judge(result);
}
}

View File

@@ -59,6 +59,7 @@ public class MobileController {
private final LocationService locationService;
private final DeveloperService developerService;
private final ApkInstallService apkInstallService;
private final AppIconService appIconService;
private final SystemInfoService systemInfoService;
private final HardwareInfoService hardwareInfoService;
private final NetworkInfoService networkInfoService;
@@ -89,7 +90,7 @@ public class MobileController {
@Operation(summary = "上传设备硬件信息")
@PostMapping("/update_system_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_SYSTEM_INFO)
public Result<Void> updateSystemInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnSystemInfoReq snSystemInfoReq
@@ -136,7 +137,7 @@ public class MobileController {
@Operation(summary = "上传设备其他信息")
@PostMapping("/update_other_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_OTHER_INFO)
public Result<Void> updateOtherInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnOtherInfoReq snOtherInfoReq
@@ -183,7 +184,7 @@ public class MobileController {
@Operation(summary = "上传设备硬件信息")
@PostMapping("/update_hardware_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_HARDWARE_INFO)
public Result<Void> updateHardwareInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnHardwareInfoReq snHardwareInfoReq
@@ -230,7 +231,7 @@ public class MobileController {
@Operation(summary = "上传设备网络信息")
@PostMapping("/update_network_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_NETWORK_INFO)
public Result<Void> updateNetworkInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnNetworkInfoReq snNetworkInfoReq
@@ -277,7 +278,7 @@ public class MobileController {
@Operation(summary = "上传设备安全信息")
@PostMapping("/update_security_info")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPDATE_SECURITY_INFO)
public Result<Void> updateSecurityInfo(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody SnSecurityInfoReq snSecurityInfoReq
@@ -432,7 +433,7 @@ public class MobileController {
@Operation(summary = "获取开发者选项开关")
@GetMapping("/get_developer_options")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.VIEW_DEVELOPER_OPTIONS)
public Result<DeveloperOptionsVO> getDeveloperOptions(@RequestHeader(value = "X-Device-SN") String sn) {
try {
if (sn == null || sn.trim().isEmpty()) {
@@ -453,7 +454,7 @@ public class MobileController {
@Operation(summary = "上传设备已安装应用列表")
@PostMapping("/upload_install_apks")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPLOAD_APK_LIST)
public Result<Void> uploadInstallApks(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestBody List<ApkInstallInfoReq> apkInfos
@@ -482,23 +483,26 @@ public class MobileController {
}
}
@Operation(summary = "获取设备已安装应用列表")
@GetMapping("/get_install_apks")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER)
public Result<List<ApkInstallInfoReq>> getInstallApks(@RequestHeader(value = "X-Device-SN") String sn) {
@Operation(summary = "上传应用图标")
@PostMapping("/upload_app_icon")
@Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.UPLOAD_APP_ICON)
public Result<String> uploadAppIcon(
@RequestHeader(value = "X-Device-SN") String sn,
@RequestParam String packageName,
@RequestPart(value = "file") MultipartFile file
) {
try {
if (sn == null || sn.trim().isEmpty()) {
return Result.failed("设备序列号不能为空");
}
List<ApkInstallInfoReq> apkInfos = apkInstallService.getDeviceApkInfo(sn);
logger.info("获取应用列表成功, sn: {}, count: {}", sn, apkInfos.size());
return Result.success(apkInfos);
if (packageName == null || packageName.trim().isEmpty()) {
return Result.failed("应用包名不能为空");
}
String iconUrl = appIconService.uploadIcon(packageName, file, "device");
return Result.success(iconUrl);
} catch (Exception e) {
logger.error("getInstallApks error, sn: {}", sn, e);
return Result.failed("获取应用列表失败: " + e.getMessage());
logger.error("uploadAppIcon error, sn: {}", sn, e);
return Result.failed("上传应用图标失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,12 @@
package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.AppIcon;
import org.apache.ibatis.annotations.Mapper;
/**
* 应用图标库Mapper
*/
@Mapper
public interface AppIconMapper extends BaseMapper<AppIcon> {
}

View File

@@ -0,0 +1,73 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 应用图标库实体(含历史版本)
* <p>
* 同一包名可存在多个版本status=1 为当前生效(唯一一条),
* status=0 为历史版本locked=1 的当前图标不可被上传覆盖。
*
* @author TTSTD
* @since 2026-08-05
*/
@Getter
@Setter
@TableName("app_icon")
public class AppIcon extends BaseEntity {
/**
* 应用包名
*/
private String packageName;
/**
* 图标访问地址(相对路径,如 /api/v1/device/app_icon/{md5}.png
*/
private String iconUrl;
/**
* 图标文件MD5内容指纹用于去重与换图标检测
*/
private String iconMd5;
/**
* 版本号同包名内自增从1开始
*/
private Integer version;
/**
* 状态1当前生效 0历史版本
*/
private Integer status;
/**
* 是否锁定0未锁定 1已锁定锁定后不可上传覆盖
*/
private Integer locked;
/**
* 图标来源device设备端上报 admin管理端上传
*/
private String source;
/**
* 创建人ID
*/
private Long createBy;
/**
* 更新人ID
*/
private Long updateBy;
/**
* 逻辑删除0未删 1已删
*/
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,26 @@
package com.youlai.boot.device.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备应用操作表单(打开/清除数据/卸载)
*
* @author TTSTD
* @since 2026-08-05
*/
@Data
@Schema(description = "设备应用操作Form")
public class AppActionForm implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotBlank(message = "应用包名不能为空")
@Schema(description = "应用包名")
private String packageName;
}

View File

@@ -0,0 +1,52 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 应用图标VO
*
* @author TTSTD
* @since 2026-08-05
*/
@Data
@Schema(description = "应用图标VO")
public class AppIconVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键ID")
private Long id;
@Schema(description = "应用包名")
private String packageName;
@Schema(description = "图标访问地址")
private String iconUrl;
@Schema(description = "图标文件MD5")
private String iconMd5;
@Schema(description = "版本号(同包名内自增)")
private Integer version;
@Schema(description = "状态1当前生效 0历史版本")
private Integer status;
@Schema(description = "是否锁定0未锁定 1已锁定")
private Integer locked;
@Schema(description = "图标来源device/admin")
private String source;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,58 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 设备已安装应用信息 VO前端展示用
*
* @author TTSTD
* @since 2026-08-05
*/
@Data
@Schema(description = "设备已安装应用信息VO")
public class DeviceApkInfoVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "包名")
private String packageName;
@Schema(description = "应用名称")
private String appName;
@Schema(description = "版本名称")
private String versionName;
@Schema(description = "版本号")
private Long versionCode;
@Schema(description = "安装时间")
private Date installTime;
@Schema(description = "最后更新时间")
private Date lastUpdateTime;
@Schema(description = "Apk文件大小")
private Long apkSize;
@Schema(description = "App数据占用空间")
private Long dataSize;
@Schema(description = "App缓存占用空间")
private Long cacheSize;
@Schema(description = "apk MD5")
private String md5;
@Schema(description = "是否为系统应用")
private boolean systemApp;
@Schema(description = "应用图标访问地址")
private String iconUrl;
}

View File

@@ -1,6 +1,7 @@
package com.youlai.boot.device.service;
import com.youlai.boot.device.model.req.ApkInstallInfoReq;
import com.youlai.boot.device.model.vo.DeviceApkInfoVO;
import java.util.List;
@@ -8,5 +9,5 @@ public interface ApkInstallService {
boolean saveOrUpdateDeviceApkInfo(String sn, List<ApkInstallInfoReq> apkInfos);
List<ApkInstallInfoReq> getDeviceApkInfo(String sn);
List<DeviceApkInfoVO> getDeviceApkInfo(String sn);
}

View File

@@ -0,0 +1,68 @@
package com.youlai.boot.device.service;
import com.youlai.boot.device.model.vo.AppIconVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 应用图标库服务接口
*
* @author TTSTD
* @since 2026-08-05
*/
public interface AppIconService {
/**
* 获取应用当前生效图标
*
* @param packageName 应用包名
* @return 当前生效图标(无则返回 null
*/
AppIconVO getCurrentIcon(String packageName);
/**
* 获取应用全部图标版本(含当前,按版本倒序)
*
* @param packageName 应用包名
* @return 图标版本列表
*/
List<AppIconVO> getHistory(String packageName);
/**
* 选取历史版本并锁定(选中版本成为当前生效且锁定,原当前版本转为历史)
*
* @param packageName 应用包名
* @param iconId 选中图标记录ID
* @return 是否成功
*/
boolean lockIcon(String packageName, Long iconId);
/**
* 解锁当前生效图标(解锁后可上传新版本覆盖)
*
* @param packageName 应用包名
* @return 是否成功
*/
boolean unlockIcon(String packageName);
/**
* 上传应用图标(全局库:同包名未锁定时新版本替换当前,锁定则拒绝)
*
* @param packageName 应用包名
* @param file 图标文件jpg/jpeg/png/webp
* @param source 来源device/admin
* @return 图标访问地址
*/
String uploadIcon(String packageName, MultipartFile file, String source);
/**
* 批量查询多个应用当前生效图标地址
*
* @param packageNames 应用包名集合
* @return packageName -> iconUrl
*/
Map<String, String> getActiveIconUrls(Collection<String> packageNames);
}

View File

@@ -103,6 +103,33 @@ public interface DeviceService extends IService<SnDeviceInfo> {
boolean setDeviceDeveloper(String sn);
/**
* 打开应用
*
* @param sn 设备序列号
* @param packageName 应用包名
* @return 是否推送成功
*/
boolean launchApp(String sn, String packageName);
/**
* 清除应用数据
*
* @param sn 设备序列号
* @param packageName 应用包名
* @return 是否推送成功
*/
boolean clearAppData(String sn, String packageName);
/**
* 卸载应用
*
* @param sn 设备序列号
* @param packageName 应用包名
* @return 是否推送成功
*/
boolean uninstallApp(String sn, String packageName);
/**
* 新增开发者选项配置
*

View File

@@ -2,8 +2,10 @@ package com.youlai.boot.device.service.impl;
import com.youlai.boot.device.model.document.ApkInstallDocument;
import com.youlai.boot.device.model.req.ApkInstallInfoReq;
import com.youlai.boot.device.model.vo.DeviceApkInfoVO;
import com.youlai.boot.device.repository.ApkInstallRepository;
import com.youlai.boot.device.service.ApkInstallService;
import com.youlai.boot.device.service.AppIconService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -13,6 +15,7 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -22,6 +25,7 @@ import java.util.stream.Collectors;
public class ApkInstallServiceImpl implements ApkInstallService {
private final ApkInstallRepository apkInstallRepository;
private final AppIconService appIconService;
@Override
public boolean saveOrUpdateDeviceApkInfo(String sn, List<ApkInstallInfoReq> apkInfos) {
@@ -65,7 +69,7 @@ public class ApkInstallServiceImpl implements ApkInstallService {
}
@Override
public List<ApkInstallInfoReq> getDeviceApkInfo(String sn) {
public List<DeviceApkInfoVO> getDeviceApkInfo(String sn) {
Optional<ApkInstallDocument> documentOpt = apkInstallRepository.findBySn(sn);
if (!documentOpt.isPresent() || documentOpt.get().getApkList() == null) {
@@ -75,19 +79,27 @@ public class ApkInstallServiceImpl implements ApkInstallService {
ApkInstallDocument document = documentOpt.get();
List<ApkInstallDocument.ApkInfo> apkInfoList = document.getApkList();
// 批量查询全局图标库中这些应用的当前生效图标
List<String> packageNames = apkInfoList.stream()
.map(ApkInstallDocument.ApkInfo::getPackageName)
.filter(p -> p != null && !p.isBlank())
.toList();
Map<String, String> activeIconUrls = appIconService.getActiveIconUrls(packageNames);
return apkInfoList.stream().map(apkInfo -> {
ApkInstallInfoReq req = new ApkInstallInfoReq();
BeanUtils.copyProperties(apkInfo, req);
DeviceApkInfoVO vo = new DeviceApkInfoVO();
BeanUtils.copyProperties(apkInfo, vo);
vo.setIconUrl(activeIconUrls.get(apkInfo.getPackageName()));
if (apkInfo.getInstallTime() != null) {
req.setInstallTime(Date.from(apkInfo.getInstallTime().atZone(ZoneId.systemDefault()).toInstant()));
vo.setInstallTime(Date.from(apkInfo.getInstallTime().atZone(ZoneId.systemDefault()).toInstant()));
}
if (apkInfo.getLastUpdateTime() != null) {
req.setLastUpdateTime(Date.from(apkInfo.getLastUpdateTime().atZone(ZoneId.systemDefault()).toInstant()));
vo.setLastUpdateTime(Date.from(apkInfo.getLastUpdateTime().atZone(ZoneId.systemDefault()).toInstant()));
}
return req;
return vo;
}).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,241 @@
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.util.HashUtils;
import com.youlai.boot.device.mapper.AppIconMapper;
import com.youlai.boot.device.model.entity.AppIcon;
import com.youlai.boot.device.model.vo.AppIconVO;
import com.youlai.boot.device.service.AppIconService;
import com.youlai.boot.framework.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 应用图标库服务实现类
*
* @author TTSTD
* @since 2026-08-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> implements AppIconService {
/** 状态:当前生效 */
private static final int STATUS_ACTIVE = 1;
/** 状态:历史版本 */
private static final int STATUS_HISTORY = 0;
/** 锁定 */
private static final int LOCKED = 1;
/** 未锁定 */
private static final int UNLOCKED = 0;
/** 允许上传的图标格式 */
private static final Set<String> ICON_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp");
@Override
public AppIconVO getCurrentIcon(String packageName) {
AppIcon entity = this.lambdaQuery()
.eq(AppIcon::getPackageName, packageName)
.eq(AppIcon::getStatus, STATUS_ACTIVE)
.one();
return entity == null ? null : toVo(entity);
}
@Override
public List<AppIconVO> getHistory(String packageName) {
List<AppIcon> entities = this.lambdaQuery()
.eq(AppIcon::getPackageName, packageName)
.orderByDesc(AppIcon::getVersion)
.list();
return entities.stream().map(this::toVo).toList();
}
@Override
public boolean lockIcon(String packageName, Long iconId) {
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
Assert.notNull(iconId, "图标记录ID不能为空");
AppIcon target = this.getById(iconId);
Assert.notNull(target, "图标记录不存在");
Assert.isTrue(packageName.equals(target.getPackageName()), "图标记录不属于该应用");
AppIcon current = getActiveEntity(packageName);
if (current != null && current.getId().equals(iconId)) {
// 已是当前版本,仅置为锁定
if (target.getLocked() == LOCKED) {
return true;
}
target.setLocked(LOCKED);
target.setUpdateBy(SecurityUtils.getUserId());
return this.updateById(target);
}
// 从历史版本中选取:目标成为当前生效并锁定,原当前版本转为历史
if (current != null) {
current.setStatus(STATUS_HISTORY);
this.updateById(current);
}
target.setStatus(STATUS_ACTIVE);
target.setLocked(LOCKED);
target.setUpdateBy(SecurityUtils.getUserId());
return this.updateById(target);
}
@Override
public boolean unlockIcon(String packageName) {
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
AppIcon current = getActiveEntity(packageName);
Assert.notNull(current, "该应用暂无图标,无法解锁");
if (current.getLocked() == UNLOCKED) {
return true;
}
current.setLocked(UNLOCKED);
current.setUpdateBy(SecurityUtils.getUserId());
return this.updateById(current);
}
@Override
public String uploadIcon(String packageName, MultipartFile file, String source) {
Assert.isTrue(file != null && !file.isEmpty(), "上传文件不能为空");
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
Assert.isTrue(extension != null && ICON_EXTENSIONS.contains(extension.toLowerCase()),
"仅支持 jpg/jpeg/png/webp 格式图标");
// 计算内容指纹(先算 md5再做后续判断
String md5;
try {
md5 = HashUtils.calculateMultipartFileMd5(file);
} catch (NoSuchAlgorithmException | IOException e) {
log.error("计算图标文件MD5失败, packageName: {}", packageName, e);
throw new RuntimeException("计算图标文件MD5失败", e);
}
AppIcon current = getActiveEntity(packageName);
// 内容未变化:直接返回现有图标地址
if (current != null && md5.equals(current.getIconMd5())) {
return current.getIconUrl();
}
// 已锁定:拒绝覆盖(规避三方 App 换图标带广告)
if (current != null && current.getLocked() == LOCKED) {
throw new RuntimeException("图标已锁定,不可修改,请先解锁");
}
// 保存文件(内容寻址 {md5}.{ext},文件已存在则复用)
String iconUrl = saveIconFile(md5, extension.toLowerCase(), file);
Long userId = SecurityUtils.getUserId();
if (current != null) {
// 原当前版本转为历史
current.setStatus(STATUS_HISTORY);
current.setUpdateBy(userId);
this.updateById(current);
AppIcon next = new AppIcon();
next.setPackageName(packageName);
next.setIconUrl(iconUrl);
next.setIconMd5(md5);
next.setVersion(current.getVersion() + 1);
next.setStatus(STATUS_ACTIVE);
next.setLocked(UNLOCKED);
next.setSource(source);
next.setCreateBy(userId);
next.setUpdateBy(userId);
this.save(next);
} else {
AppIcon icon = new AppIcon();
icon.setPackageName(packageName);
icon.setIconUrl(iconUrl);
icon.setIconMd5(md5);
icon.setVersion(1);
icon.setStatus(STATUS_ACTIVE);
icon.setLocked(UNLOCKED);
icon.setSource(source);
icon.setCreateBy(userId);
icon.setUpdateBy(userId);
this.save(icon);
}
log.info("应用图标上传成功, packageName: {}, md5: {}, iconUrl: {}", packageName, md5, iconUrl);
return iconUrl;
}
@Override
public Map<String, String> getActiveIconUrls(Collection<String> packageNames) {
if (packageNames == null || packageNames.isEmpty()) {
return Map.of();
}
List<AppIcon> activeIcons = this.lambdaQuery()
.in(AppIcon::getPackageName, packageNames)
.eq(AppIcon::getStatus, STATUS_ACTIVE)
.list();
Map<String, String> urlMap = new HashMap<>();
for (AppIcon icon : activeIcons) {
urlMap.put(icon.getPackageName(), icon.getIconUrl());
}
return urlMap;
}
/**
* 查询当前生效的图标记录
*/
private AppIcon getActiveEntity(String packageName) {
return this.lambdaQuery()
.eq(AppIcon::getPackageName, packageName)
.eq(AppIcon::getStatus, STATUS_ACTIVE)
.one();
}
/**
* 保存图标文件(内容寻址 {md5}.{ext},文件已存在则复用)
*/
private String saveIconFile(String md5, String extension, MultipartFile file) {
String iconDirPath = FilePath.getApkIconPath();
File iconDir = new File(iconDirPath);
if (!iconDir.exists() && !iconDir.mkdirs()) {
throw new RuntimeException("创建图标目录失败: " + iconDirPath);
}
String fileName = md5 + "." + extension;
File destFile = new File(iconDir, fileName);
if (destFile.exists()) {
return "/api/v1/device/app_icon/" + fileName;
}
try {
file.transferTo(destFile);
} catch (IOException e) {
log.error("保存图标文件失败, fileName: {}", fileName, e);
throw new RuntimeException("保存应用图标失败");
}
return "/api/v1/device/app_icon/" + fileName;
}
private AppIconVO toVo(AppIcon entity) {
AppIconVO vo = new AppIconVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}
}

View File

@@ -291,6 +291,65 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
}
}
/**
* 构建携带应用包名的推送参数
*/
private PushSendParam getAppPushSendParam(String sn, String type, String title, String content, String packageName) {
PushSendParam pushSendParam = getSinglePushSendParam(sn, type, title, content);
Map<String, Object> extras = new HashMap<>();
extras.put("package_name", packageName);
pushSendParam.getCustom().setExtras(extras);
return pushSendParam;
}
@Override
public boolean launchApp(String sn, String packageName) {
PushSendParam pushSendParam = getAppPushSendParam(sn, "8", "appLaunch", "launch", 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("launchApp send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean clearAppData(String sn, String packageName) {
PushSendParam pushSendParam = getAppPushSendParam(sn, "9", "appClearData", "clearData", 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("clearAppData send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean uninstallApp(String sn, String packageName) {
PushSendParam pushSendParam = getAppPushSendParam(sn, "10", "appUninstall", "uninstall", 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("uninstallApp send error, httpStatus:{} code:{}, message:{}", httpStatus, errorCode, errorMessage);
return false;
}
}
@Override
public boolean addDeveloperConfig(String sn, Integer developerOptions) {
try {