diff --git a/sql/app_icon.sql b/sql/app_icon.sql new file mode 100644 index 00000000..d16f4bc9 --- /dev/null +++ b/sql/app_icon.sql @@ -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 = '应用图标库(含历史版本)'; diff --git a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java new file mode 100644 index 00000000..76dd024f --- /dev/null +++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java @@ -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); + } +} 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 db17fc83..32949a35 100644 --- a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java @@ -40,6 +40,20 @@ public enum ActionTypeEnum implements IBaseEnum { 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 diff --git a/src/main/java/com/youlai/boot/device/controller/AppIconController.java b/src/main/java/com/youlai/boot/device/controller/AppIconController.java new file mode 100644 index 00000000..9831ac2a --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/AppIconController.java @@ -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 getCurrentIcon(@PathVariable String packageName) { + return Result.success(appIconService.getCurrentIcon(packageName)); + } + + @Operation(summary = "获取应用图标历史版本列表(含当前,按版本倒序)") + @GetMapping("/{packageName}/history") + @Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.LIST) + public Result> 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 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")); + } +} diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceController.java b/src/main/java/com/youlai/boot/device/controller/DeviceController.java index 4782c58e..0087a77d 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceController.java @@ -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> getApkList( + @Parameter(description = "设备序列号") @PathVariable("sn") String sn) { + return Result.success(apkInstallService.getDeviceApkInfo(sn)); + } + } 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 77bc503c..c7976dcf 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java @@ -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); + } } 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 d397b5f0..b4098b63 100644 --- a/src/main/java/com/youlai/boot/device/controller/MobileController.java +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -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 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 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 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 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 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 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 uploadInstallApks( @RequestHeader(value = "X-Device-SN") String sn, @RequestBody List apkInfos @@ -482,23 +483,26 @@ public class MobileController { } } - @Operation(summary = "获取设备已安装应用列表") - @GetMapping("/get_install_apks") - @Log(module = LogModuleEnum.MOBILE, value = ActionTypeEnum.DEVELOPER) - public Result> 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 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 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()); } } } diff --git a/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java b/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java new file mode 100644 index 00000000..d4c0658a --- /dev/null +++ b/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java @@ -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 { +} 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 new file mode 100644 index 00000000..b407c835 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java @@ -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; + +/** + * 应用图标库实体(含历史版本) + *

+ * 同一包名可存在多个版本: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; +} diff --git a/src/main/java/com/youlai/boot/device/model/form/AppActionForm.java b/src/main/java/com/youlai/boot/device/model/form/AppActionForm.java new file mode 100644 index 00000000..1cc45d9c --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/form/AppActionForm.java @@ -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; +} 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 new file mode 100644 index 00000000..2b4f1204 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/AppIconVO.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/model/vo/DeviceApkInfoVO.java b/src/main/java/com/youlai/boot/device/model/vo/DeviceApkInfoVO.java new file mode 100644 index 00000000..cab388ad --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DeviceApkInfoVO.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/device/service/ApkInstallService.java b/src/main/java/com/youlai/boot/device/service/ApkInstallService.java index ab0183db..c5bde571 100644 --- a/src/main/java/com/youlai/boot/device/service/ApkInstallService.java +++ b/src/main/java/com/youlai/boot/device/service/ApkInstallService.java @@ -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 apkInfos); - List getDeviceApkInfo(String sn); + List getDeviceApkInfo(String sn); } diff --git a/src/main/java/com/youlai/boot/device/service/AppIconService.java b/src/main/java/com/youlai/boot/device/service/AppIconService.java new file mode 100644 index 00000000..b111aded --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/AppIconService.java @@ -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 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 getActiveIconUrls(Collection packageNames); +} 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 2e0132ea..e445b32d 100644 --- a/src/main/java/com/youlai/boot/device/service/DeviceService.java +++ b/src/main/java/com/youlai/boot/device/service/DeviceService.java @@ -103,6 +103,33 @@ public interface DeviceService extends IService { 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); + /** * 新增开发者选项配置 * diff --git a/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java index 7eea00a3..fd0bf49a 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java @@ -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 apkInfos) { @@ -65,7 +69,7 @@ public class ApkInstallServiceImpl implements ApkInstallService { } @Override - public List getDeviceApkInfo(String sn) { + public List getDeviceApkInfo(String sn) { Optional 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 apkInfoList = document.getApkList(); + // 批量查询全局图标库中这些应用的当前生效图标 + List packageNames = apkInfoList.stream() + .map(ApkInstallDocument.ApkInfo::getPackageName) + .filter(p -> p != null && !p.isBlank()) + .toList(); + Map 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()); } } 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 new file mode 100644 index 00000000..fa6475d2 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java @@ -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 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 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 getHistory(String packageName) { + List 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 getActiveIconUrls(Collection packageNames) { + if (packageNames == null || packageNames.isEmpty()) { + return Map.of(); + } + List activeIcons = this.lambdaQuery() + .in(AppIcon::getPackageName, packageNames) + .eq(AppIcon::getStatus, STATUS_ACTIVE) + .list(); + Map 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; + } +} 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 47f0beec..4fc28b84 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 @@ -291,6 +291,65 @@ public class DeviceServiceImpl extends ServiceImpl i } } + /** + * 构建携带应用包名的推送参数 + */ + private PushSendParam getAppPushSendParam(String sn, String type, String title, String content, String packageName) { + PushSendParam pushSendParam = getSinglePushSendParam(sn, type, title, content); + Map 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 {