From 8ee05af6c7abcc0c7b36450fdda1be881a9ec53e Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Fri, 7 Aug 2026 12:20:04 +0800 Subject: [PATCH] feat(app): add app category management and package repository with apk parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增应用分类管理(MongoDB CRUD + 图标上传)和应用安装包资源库(上传/分页/详情/删除),集成apk-parser自动解析APK元数据(包名、版本、权限、CPU架构、图标),并添加对应的SQL菜单初始化脚本与授权配置。 --- docs/sql/app_category_menu.sql | 29 ++ docs/sql/app_manager_menu.sql | 56 ++++ docs/sql/app_package_menu.sql | 55 ++++ pom.xml | 7 + .../controller/AppCategoryController.java | 102 +++++++ .../model/document/AppCategoryDocument.java | 52 ++++ .../category/model/form/AppCategoryForm.java | 27 ++ .../app/category/model/vo/AppCategoryVO.java | 38 +++ .../repository/AppCategoryRepository.java | 37 +++ .../impl/AppCategoryRepositoryImpl.java | 55 ++++ .../category/service/AppCategoryService.java | 37 +++ .../service/impl/AppCategoryServiceImpl.java | 98 +++++++ .../app/packages/component/ApkMetaParser.java | 117 ++++++++ .../controller/AppPackageController.java | 88 ++++++ .../model/document/AppPackageDocument.java | 122 +++++++++ .../model/query/AppPackagePageQuery.java | 18 ++ .../app/packages/model/vo/AppPackageVO.java | 84 ++++++ .../repository/AppPackageRepository.java | 41 +++ .../impl/AppPackageRepositoryImpl.java | 84 ++++++ .../packages/service/AppPackageService.java | 52 ++++ .../service/impl/AppPackageServiceImpl.java | 254 ++++++++++++++++++ .../youlai/boot/common/config/FilePath.java | 10 + .../boot/common/config/WebMvcConfig.java | 14 + src/main/resources/application-dev.yml | 2 +- src/main/resources/application-prod.yml | 2 +- 25 files changed, 1479 insertions(+), 2 deletions(-) create mode 100644 docs/sql/app_category_menu.sql create mode 100644 docs/sql/app_manager_menu.sql create mode 100644 docs/sql/app_package_menu.sql create mode 100644 src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java create mode 100644 src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java create mode 100644 src/main/java/com/youlai/boot/app/category/model/form/AppCategoryForm.java create mode 100644 src/main/java/com/youlai/boot/app/category/model/vo/AppCategoryVO.java create mode 100644 src/main/java/com/youlai/boot/app/category/repository/AppCategoryRepository.java create mode 100644 src/main/java/com/youlai/boot/app/category/repository/impl/AppCategoryRepositoryImpl.java create mode 100644 src/main/java/com/youlai/boot/app/category/service/AppCategoryService.java create mode 100644 src/main/java/com/youlai/boot/app/category/service/impl/AppCategoryServiceImpl.java create mode 100644 src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java create mode 100644 src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java create mode 100644 src/main/java/com/youlai/boot/app/packages/model/document/AppPackageDocument.java create mode 100644 src/main/java/com/youlai/boot/app/packages/model/query/AppPackagePageQuery.java create mode 100644 src/main/java/com/youlai/boot/app/packages/model/vo/AppPackageVO.java create mode 100644 src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java create mode 100644 src/main/java/com/youlai/boot/app/packages/repository/impl/AppPackageRepositoryImpl.java create mode 100644 src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java create mode 100644 src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java diff --git a/docs/sql/app_category_menu.sql b/docs/sql/app_category_menu.sql new file mode 100644 index 00000000..ee74b3cb --- /dev/null +++ b/docs/sql/app_category_menu.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- 应用分类管理 菜单初始化(幂等,可重复执行) +-- ------------------------------------------------------------ +-- 说明: +-- 挂在「应用管理」目录(/app-manager)下,新增「分类管理」菜单。 +-- 页面路径:app-manager/category/index +-- ============================================================ + +SET @parent_id = (SELECT id FROM sys_menu WHERE route_path = '/app-manager' AND type = 'C' LIMIT 1); + +INSERT INTO sys_menu + (parent_id, tree_path, name, type, route_name, route_path, component, + perm, always_show, keep_alive, visible, sort, icon, redirect, + create_time, update_time, params) +SELECT @parent_id, + CONCAT('0,', @parent_id), + '分类管理', 'M', 'AppCategory', 'category', 'app-manager/category/index', + NULL, NULL, 1, 1, 2, 'menu', NULL, + now(), now(), NULL +FROM DUAL +WHERE @parent_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE component = 'app-manager/category/index'); + +-- 授权给 ADMIN 角色(role_id=2,如实际不同请修改) +SET @menu_id = (SELECT id FROM sys_menu WHERE component = 'app-manager/category/index' LIMIT 1); +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT 2, @menu_id +FROM DUAL +WHERE @menu_id IS NOT NULL; diff --git a/docs/sql/app_manager_menu.sql b/docs/sql/app_manager_menu.sql new file mode 100644 index 00000000..7187e326 --- /dev/null +++ b/docs/sql/app_manager_menu.sql @@ -0,0 +1,56 @@ +-- ============================================================ +-- 应用管理 / 应用上传 菜单修复与初始化(幂等,可重复执行) +-- ------------------------------------------------------------ +-- 说明: +-- 本项目使用 vue3-element-admin,后端动态菜单约定: +-- - 目录(type='C')的 component 必须是 'Layout'(顶层目录必须有 Layout 壳), +-- 否则 transformRoutes 不会包裹 Layout,点击进入页面会白屏/一直 loading。 +-- - 菜单(type='M')的 component 才是页面路径,如 'app-manager/upload-package/index'。 +-- +-- 本脚本: +-- 1. 修正「应用管理」目录 (route_path=/app-manager) 的 component = 'Layout'(关键修复白屏)。 +-- 2. 确保「应用上传」菜单存在且 component 指向正确页面。 +-- 3. 授权给 ADMIN 角色 (role_id=2,如不同请改)。 +-- ============================================================ + +-- 1. 修正「应用管理」目录(无论是否已存在,都强制 component='Layout') +INSERT INTO sys_menu + (parent_id, tree_path, name, type, route_name, route_path, component, + perm, always_show, keep_alive, visible, sort, icon, redirect, + create_time, update_time, params) +SELECT 0, '0', '应用管理', 'C', '', '/app-manager', 'Layout', + NULL, 1, NULL, 1, 3, 'box', '/app-manager/upload-package', + now(), now(), NULL +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/app-manager' AND type = 'C'); + +-- 把已存在但 component 不是 Layout 的「应用管理」目录修正为 Layout(关键) +UPDATE sys_menu +SET component = 'Layout', + always_show = 1, + redirect = '/app-manager/upload-package', + update_time = now() +WHERE route_path = '/app-manager' AND type = 'C' AND component <> 'Layout'; + +-- 2. 确保「应用上传」菜单存在(挂在应用管理目录下) +SET @parent_id = (SELECT id FROM sys_menu WHERE route_path = '/app-manager' AND type = 'C' LIMIT 1); + +INSERT INTO sys_menu + (parent_id, tree_path, name, type, route_name, route_path, component, + perm, always_show, keep_alive, visible, sort, icon, redirect, + create_time, update_time, params) +SELECT @parent_id, + CONCAT('0,', @parent_id), + '应用上传', 'M', 'UploadPackage', 'upload-package', 'app-manager/upload-package/index', + NULL, NULL, 1, 1, 1, 'upload', NULL, + now(), now(), NULL +FROM DUAL +WHERE @parent_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE component = 'app-manager/upload-package/index'); + +-- 3. 授权给 ADMIN 角色(role_id=2,如实际不同请修改) +SET @menu_id = (SELECT id FROM sys_menu WHERE component = 'app-manager/upload-package/index' LIMIT 1); +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT 2, @menu_id +FROM DUAL +WHERE @menu_id IS NOT NULL; diff --git a/docs/sql/app_package_menu.sql b/docs/sql/app_package_menu.sql new file mode 100644 index 00000000..c3cd5de8 --- /dev/null +++ b/docs/sql/app_package_menu.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- 应用安装包资源库 - 后台菜单与权限初始化脚本(可选) +-- ------------------------------------------------------------ +-- 说明: +-- 1. 前端路由已在 constantRoutes 中注册,应用列表页面可直接通过 +-- /#/app/package 访问,无需执行本脚本也能使用上传/列表功能。 +-- 2. 本脚本仅用于在【后台管理 -> 系统管理 -> 菜单管理】侧边栏中 +-- 展示“应用列表”入口,并将权限授予所有角色。 +-- 3. 请在执行前确认第 1 步查询出的父目录(通常为“设备”目录)的 ID, +-- 若父目录 route_path 不为 'devices',请修改下面的 @parent_id 取值条件。 +-- 4. 本脚本仅 INSERT,可重复执行(已包含存在性判断)。 +-- ============================================================ + +-- 0. 变量:自动定位“设备”目录作为父级(如实际父目录不同请修改条件) +SET @parent_id = (SELECT id FROM sys_menu WHERE type = 'C' AND route_path = 'devices' LIMIT 1); +-- 若未找到设备目录,则挂到根目录(id=0) +SET @parent_id = IFNULL(@parent_id, 0); + +-- 1. 插入“应用列表”菜单(作为设备目录下的子菜单) +INSERT INTO sys_menu + (parent_id, name, type, route_name, route_path, component, perm, visible, sort, icon, redirect, tree_path, keep_alive, always_show, create_time, update_time) +SELECT + @parent_id, + '应用列表', + 'M', + 'AppPackage', + 'app/package', + 'app/package/index', + 'app:package:list', + 1, + 100, + 'box', + NULL, + CONCAT(IF(@parent_id = 0, '', CONCAT(@parent_id, ',')), 'APP_PACKAGE_PLACEHOLDER'), + 1, + 0, + NOW(), + NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE route_name = 'AppPackage' AND route_path = 'app/package' +); + +-- 更新 tree_path(去掉占位并写入真实自增 id) +SET @menu_id = (SELECT id FROM sys_menu WHERE route_name = 'AppPackage' AND route_path = 'app/package' LIMIT 1); +UPDATE sys_menu +SET tree_path = IF(@parent_id = 0, CONCAT(@menu_id, ','), CONCAT(@parent_id, ',', @menu_id, ',')) +WHERE id = @menu_id; + +-- 2. 将“应用列表”菜单权限授予所有角色 +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT r.id, @menu_id +FROM sys_role r +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.id AND rm.menu_id = @menu_id +); diff --git a/pom.xml b/pom.xml index 7d940ad1..fcd31de6 100644 --- a/pom.xml +++ b/pom.xml @@ -128,6 +128,13 @@ spring-boot-starter-data-mongodb + + + net.dongliu + apk-parser + 2.6.10 + + org.springframework.boot spring-boot-starter-cache diff --git a/src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java b/src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java new file mode 100644 index 00000000..ce3f81ee --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java @@ -0,0 +1,102 @@ +package com.youlai.boot.app.category.controller; + +import com.youlai.boot.app.category.model.form.AppCategoryForm; +import com.youlai.boot.app.category.model.vo.AppCategoryVO; +import com.youlai.boot.app.category.service.AppCategoryService; +import com.youlai.boot.common.config.FilePath; +import com.youlai.boot.common.result.Result; +import cn.hutool.core.io.FileUtil; +import cn.hutool.crypto.digest.DigestUtil; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * 应用分类管理控制层 + */ +@Tag(name = "应用分类") +@RestController +@RequestMapping("/api/v1/app/category") +@RequiredArgsConstructor +@Slf4j +public class AppCategoryController { + + private final AppCategoryService categoryService; + + @Operation(summary = "上传分类图标(手动上传图片,返回可访问 URL)") + @PostMapping("/icon/upload") + public Result uploadIcon( + @Parameter(description = "图标图片文件(png/jpg/jpeg/webp)") @RequestPart("file") MultipartFile file + ) { + if (file == null || file.isEmpty()) { + return Result.failed("图标文件不能为空"); + } + String suffix = FileUtil.getSuffix(file.getOriginalFilename()); + if (suffix == null || !List.of("png", "jpg", "jpeg", "webp").contains(suffix.toLowerCase())) { + return Result.failed("图标仅支持 png / jpg / jpeg / webp 格式"); + } + String dirPath = FilePath.getCategoryIconPath(); + File dir = new File(dirPath); + if (!dir.exists()) { + dir.mkdirs(); + } + // 内容寻址:md5 作为文件名,避免重复上传占用空间,且内容变更 URL 必变 + String md5; + try { + md5 = DigestUtil.md5Hex(file.getInputStream()); + } catch (IOException e) { + log.error("分类图标 md5 计算失败", e); + return Result.failed("图标处理失败"); + } + String stored = md5 + "." + suffix.toLowerCase(); + try { + file.transferTo(new File(dir, stored)); + } catch (IOException e) { + log.error("分类图标保存失败", e); + return Result.failed("图标保存失败"); + } + return Result.success("/static/category_icon/" + stored); + } + + @Operation(summary = "分类列表(全部,按排序返回)") + @GetMapping("/list") + public Result> list() { + return Result.success(categoryService.listCategories()); + } + + @Operation(summary = "分类详情") + @GetMapping("/{id}") + public Result detail(@PathVariable String id) { + return Result.success(categoryService.getById(id)); + } + + @Operation(summary = "新增分类") + @PostMapping + public Result create(@RequestBody AppCategoryForm form) { + return Result.success(categoryService.create(form)); + } + + @Operation(summary = "更新分类") + @PutMapping("/{id}") + public Result update( + @Parameter(description = "分类ID") @PathVariable String id, + @RequestBody AppCategoryForm form + ) { + return Result.success(categoryService.update(id, form)); + } + + @Operation(summary = "删除分类") + @DeleteMapping("/{id}") + public Result remove(@PathVariable String id) { + categoryService.remove(id); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java b/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java new file mode 100644 index 00000000..0acc2e36 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java @@ -0,0 +1,52 @@ +package com.youlai.boot.app.category.model.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * 应用分类文档(MongoDB) + *

+ * code 为业务唯一编码,前端上传安装包时携带该编码;name/icon/description 用于展示。 + */ +@Schema(description = "应用分类") +@Data +@Document(collection = "app_category") +public class AppCategoryDocument { + + @Schema(description = "分类ID") + @Id + private String id; + + @Schema(description = "分类编码(唯一,前端上传时携带,如 game/tools)") + @Field("code") + private String code; + + @Schema(description = "分类名称(展示用,如 游戏/工具)") + @Field("name") + private String name; + + @Schema(description = "分类图标(手动上传图片的访问 URL,如 /static/category_icon/{md5}.png)") + @Field("icon") + private String icon; + + @Schema(description = "分类描述") + @Field("description") + private String description; + + @Schema(description = "排序(越小越靠前)") + @Field("sort") + private Integer sort; + + @Schema(description = "创建时间") + @Field("create_time") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @Field("update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/category/model/form/AppCategoryForm.java b/src/main/java/com/youlai/boot/app/category/model/form/AppCategoryForm.java new file mode 100644 index 00000000..b5c9cdfe --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/model/form/AppCategoryForm.java @@ -0,0 +1,27 @@ +package com.youlai.boot.app.category.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 应用分类表单(新增/更新) + */ +@Schema(description = "应用分类表单") +@Data +public class AppCategoryForm { + + @Schema(description = "分类编码(唯一,如 game/tools)") + private String code; + + @Schema(description = "分类名称(展示用)") + private String name; + + @Schema(description = "分类图标(手动上传图片的访问 URL)") + private String icon; + + @Schema(description = "分类描述") + private String description; + + @Schema(description = "排序(越小越靠前)") + private Integer sort; +} diff --git a/src/main/java/com/youlai/boot/app/category/model/vo/AppCategoryVO.java b/src/main/java/com/youlai/boot/app/category/model/vo/AppCategoryVO.java new file mode 100644 index 00000000..3c1999e3 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/model/vo/AppCategoryVO.java @@ -0,0 +1,38 @@ +package com.youlai.boot.app.category.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 应用分类视图对象 + */ +@Schema(description = "应用分类视图对象") +@Data +public class AppCategoryVO { + + @Schema(description = "分类ID") + private String id; + + @Schema(description = "分类编码(唯一)") + private String code; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "分类图标(图片 URL)") + private String icon; + + @Schema(description = "分类描述") + private String description; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/app/category/repository/AppCategoryRepository.java b/src/main/java/com/youlai/boot/app/category/repository/AppCategoryRepository.java new file mode 100644 index 00000000..699e2435 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/repository/AppCategoryRepository.java @@ -0,0 +1,37 @@ +package com.youlai.boot.app.category.repository; + +import com.youlai.boot.app.category.model.document.AppCategoryDocument; + +import java.util.List; +import java.util.Optional; + +/** + * 应用分类仓储 + */ +public interface AppCategoryRepository { + + /** + * 查询全部分类(按 sort 升序、createTime 降序) + */ + List listAll(); + + /** + * 根据编码查询(用于唯一性校验) + */ + Optional findByCode(String code); + + /** + * 根据ID查询 + */ + Optional findById(String id); + + /** + * 保存 + */ + AppCategoryDocument save(AppCategoryDocument document); + + /** + * 根据ID删除 + */ + void deleteById(String id); +} diff --git a/src/main/java/com/youlai/boot/app/category/repository/impl/AppCategoryRepositoryImpl.java b/src/main/java/com/youlai/boot/app/category/repository/impl/AppCategoryRepositoryImpl.java new file mode 100644 index 00000000..7df51fc8 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/repository/impl/AppCategoryRepositoryImpl.java @@ -0,0 +1,55 @@ +package com.youlai.boot.app.category.repository.impl; + +import com.youlai.boot.app.category.model.document.AppCategoryDocument; +import com.youlai.boot.app.category.repository.AppCategoryRepository; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 应用分类仓储实现 + */ +@Repository +public class AppCategoryRepositoryImpl implements AppCategoryRepository { + + private final MongoTemplate mongoTemplate; + + public AppCategoryRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public List listAll() { + Query query = new Query() + .with(Sort.by(Sort.Direction.ASC, "sort")) + .with(Sort.by(Sort.Direction.DESC, "createTime")); + return mongoTemplate.find(query, AppCategoryDocument.class); + } + + @Override + public Optional findByCode(String code) { + Query query = new Query(Criteria.where("code").is(code)); + return Optional.ofNullable(mongoTemplate.findOne(query, AppCategoryDocument.class)); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(mongoTemplate.findById(id, AppCategoryDocument.class)); + } + + @Override + public AppCategoryDocument save(AppCategoryDocument document) { + return mongoTemplate.save(document); + } + + @Override + public void deleteById(String id) { + Query query = new Query(Criteria.where("_id").is(id)); + mongoTemplate.remove(query, AppCategoryDocument.class); + } +} diff --git a/src/main/java/com/youlai/boot/app/category/service/AppCategoryService.java b/src/main/java/com/youlai/boot/app/category/service/AppCategoryService.java new file mode 100644 index 00000000..ac26dd36 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/service/AppCategoryService.java @@ -0,0 +1,37 @@ +package com.youlai.boot.app.category.service; + +import com.youlai.boot.app.category.model.form.AppCategoryForm; +import com.youlai.boot.app.category.model.vo.AppCategoryVO; + +import java.util.List; + +/** + * 应用分类服务 + */ +public interface AppCategoryService { + + /** + * 查询全部分类 + */ + List listCategories(); + + /** + * 分类详情 + */ + AppCategoryVO getById(String id); + + /** + * 新增分类(code 唯一) + */ + AppCategoryVO create(AppCategoryForm form); + + /** + * 更新分类(code 唯一,排除自身) + */ + AppCategoryVO update(String id, AppCategoryForm form); + + /** + * 删除分类 + */ + void remove(String id); +} diff --git a/src/main/java/com/youlai/boot/app/category/service/impl/AppCategoryServiceImpl.java b/src/main/java/com/youlai/boot/app/category/service/impl/AppCategoryServiceImpl.java new file mode 100644 index 00000000..c77e8203 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/category/service/impl/AppCategoryServiceImpl.java @@ -0,0 +1,98 @@ +package com.youlai.boot.app.category.service.impl; + +import com.youlai.boot.app.category.model.document.AppCategoryDocument; +import com.youlai.boot.app.category.model.form.AppCategoryForm; +import com.youlai.boot.app.category.model.vo.AppCategoryVO; +import com.youlai.boot.app.category.repository.AppCategoryRepository; +import com.youlai.boot.app.category.service.AppCategoryService; +import com.youlai.boot.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 应用分类服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppCategoryServiceImpl implements AppCategoryService { + + private final AppCategoryRepository categoryRepository; + + @Override + public List listCategories() { + return categoryRepository.listAll().stream() + .map(this::toVO) + .collect(Collectors.toList()); + } + + @Override + public AppCategoryVO getById(String id) { + AppCategoryDocument doc = categoryRepository.findById(id) + .orElseThrow(() -> new BusinessException("分类不存在")); + return toVO(doc); + } + + @Override + public AppCategoryVO create(AppCategoryForm form) { + if (!StringUtils.hasText(form.getCode())) { + throw new BusinessException("分类编码不能为空"); + } + if (!StringUtils.hasText(form.getName())) { + throw new BusinessException("分类名称不能为空"); + } + if (categoryRepository.findByCode(form.getCode()).isPresent()) { + throw new BusinessException("分类编码已存在:" + form.getCode()); + } + + AppCategoryDocument doc = new AppCategoryDocument(); + BeanUtils.copyProperties(form, doc); + if (doc.getSort() == null) { + doc.setSort(0); + } + doc.setId(UUID.randomUUID().toString()); + doc.setCreateTime(LocalDateTime.now()); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(categoryRepository.save(doc)); + } + + @Override + public AppCategoryVO update(String id, AppCategoryForm form) { + AppCategoryDocument doc = categoryRepository.findById(id) + .orElseThrow(() -> new BusinessException("分类不存在")); + + if (StringUtils.hasText(form.getCode()) && !form.getCode().equals(doc.getCode())) { + categoryRepository.findByCode(form.getCode()).ifPresent(existing -> { + if (!existing.getId().equals(id)) { + throw new BusinessException("分类编码已存在:" + form.getCode()); + } + }); + } + + BeanUtils.copyProperties(form, doc, "id", "createTime"); + doc.setUpdateTime(LocalDateTime.now()); + return toVO(categoryRepository.save(doc)); + } + + @Override + public void remove(String id) { + if (categoryRepository.findById(id).isEmpty()) { + throw new BusinessException("分类不存在"); + } + categoryRepository.deleteById(id); + } + + private AppCategoryVO toVO(AppCategoryDocument doc) { + AppCategoryVO vo = new AppCategoryVO(); + BeanUtils.copyProperties(doc, vo); + return vo; + } +} diff --git a/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java b/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java new file mode 100644 index 00000000..7f10730f --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java @@ -0,0 +1,117 @@ +package com.youlai.boot.app.packages.component; + +import lombok.Data; +import net.dongliu.apk.parser.ApkFile; +import net.dongliu.apk.parser.bean.ApkMeta; +import net.dongliu.apk.parser.bean.Icon; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * APK 安装包元数据解析器(基于 apk-parser) + *

+ * 用于从 apk 文件中尽可能详细地读取包名、版本、SDK 版本、权限、CPU 架构、图标等信息。 + * aab / xapk / apks / apkm 为其他格式,本解析器不适用,由上传流程按文件基础信息处理。 + */ +@Component +public class ApkMetaParser { + + @Data + public static class ApkMetaResult { + private String packageName; + private String appLabel; + private String versionName; + private Integer versionCode; + private Integer minSdk; + private Integer targetSdk; + private Integer compileSdk; + private Integer maxSdk; + private List abiList = new ArrayList<>(); + private List permissions = new ArrayList<>(); + private String iconPath; + private byte[] iconBytes; + } + + /** + * 解析 apk 文件元数据 + */ + public ApkMetaResult parse(File apkFile) throws IOException { + ApkMetaResult result = new ApkMetaResult(); + try (ApkFile apkFileParser = new ApkFile(apkFile)) { + ApkMeta meta = apkFileParser.getApkMeta(); + + result.setPackageName(meta.getPackageName()); + result.setAppLabel(meta.getLabel()); + result.setVersionName(meta.getVersionName()); + result.setVersionCode(toInt(meta.getVersionCode())); + result.setMinSdk(toInt(meta.getMinSdkVersion())); + result.setTargetSdk(toInt(meta.getTargetSdkVersion())); + result.setCompileSdk(toInt(meta.getCompileSdkVersion())); + result.setMaxSdk(toInt(meta.getMaxSdkVersion())); + + if (meta.getUsesPermissions() != null) { + result.setPermissions(new ArrayList<>(meta.getUsesPermissions())); + } + + // CPU 架构:扫描 lib// 目录 + result.setAbiList(extractAbiList(apkFile)); + + // 图标:优先 launcher 图标,取首个图标的字节 + try { + List icons = apkFileParser.getIconFiles(); + if (icons != null && !icons.isEmpty()) { + Icon icon = icons.get(0); + result.setIconPath(icon.getPath()); + byte[] data = icon.getData(); + if (data != null && data.length > 0) { + result.setIconBytes(data); + } + } + } catch (Exception ignored) { + // 图标解析失败不影响其它字段 + } + } + return result; + } + + private List extractAbiList(File apkFile) { + Set abis = new LinkedHashSet<>(); + try (ZipFile zf = new ZipFile(apkFile)) { + java.util.Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (name.startsWith("lib/") && name.length() > 4) { + String abi = name.substring(4).split("/")[0]; + if (!abi.isBlank()) { + abis.add(abi); + } + } + } + } catch (IOException ignored) { + // 忽略 + } + return new ArrayList<>(abis); + } + + private Integer toInt(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java new file mode 100644 index 00000000..12cb78df --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java @@ -0,0 +1,88 @@ +package com.youlai.boot.app.packages.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; +import com.youlai.boot.app.packages.model.vo.AppPackageVO; +import com.youlai.boot.app.packages.service.AppPackageService; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +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.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 应用安装包资源库控制层 + *

+ * 提供 apk / apks / aab / apkm / xapk 安装包的上传、分页列表、详情与删除能力。 + * 移动端(设备侧)无需调用本接口。 + */ +@Tag(name = "应用安装包库") +@RestController +@RequestMapping("/api/v1/app/packages") +@RequiredArgsConstructor +public class AppPackageController { + + private final AppPackageService appPackageService; + + @Operation(summary = "上传安装包(apk/aab/xapk/apks/apkm),自动解析详细字段") + @PostMapping("/upload") + public Result upload( + @Parameter(description = "安装包文件(apk/aab/xapk/apks/apkm)") @RequestPart("file") MultipartFile file, + @Parameter(description = "应用包名(aab/xapk/apks/apkm 建议填写,apk 可省略)") @RequestParam(required = false) String packageName, + @Parameter(description = "版本名称(可省略,apk 自动解析)") @RequestParam(required = false) String versionName, + @Parameter(description = "版本号(可省略,apk 自动解析)") @RequestParam(required = false) String versionCode, + @Parameter(description = "备注说明") @RequestParam(required = false) String remark, + @Parameter(description = "应用分类ID列表(多选)") @RequestParam(required = false) List categories, + @Parameter(description = "应用截图文件列表(png/jpg,分目录存储)") @RequestPart(required = false) MultipartFile[] screenshots + ) { + return Result.success(appPackageService.upload(file, packageName, versionName, versionCode, remark, categories, screenshots)); + } + + @Operation(summary = "分页查询安装包列表") + @GetMapping("/page") + public PageResult page( + @Parameter(description = "页码,从1开始") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "关键字(包名/应用名/文件名)") @RequestParam(required = false) String keywords, + @Parameter(description = "类型过滤:apk/aab/xapk/apks/apkm") @RequestParam(required = false) String packageType + ) { + AppPackagePageQuery query = new AppPackagePageQuery(); + query.setKeywords(keywords); + query.setPackageType(packageType); + org.springframework.data.domain.Page springPage = + appPackageService.page(query, pageNum, pageSize); + + IPage iPage = new Page<>(pageNum, pageSize, springPage.getTotalElements()); + iPage.setRecords(springPage.getContent()); + return PageResult.success(iPage); + } + + @Operation(summary = "安装包详情") + @GetMapping("/{id}") + public Result detail(@PathVariable String id) { + return Result.success(appPackageService.detail(id)); + } + + @Operation(summary = "删除安装包(同时删除本地文件)") + @DeleteMapping("/{id}") + public Result remove(@PathVariable String id) { + appPackageService.removeById(id); + return Result.success(true); + } + + @Operation(summary = "更新安装包分类(覆盖写)") + @PutMapping("/{id}/categories") + public Result updateCategories( + @Parameter(description = "安装包ID") @PathVariable String id, + @Parameter(description = "分类编码列表") @RequestBody List categories + ) { + appPackageService.updateCategories(id, categories); + return Result.success(true); + } +} diff --git a/src/main/java/com/youlai/boot/app/packages/model/document/AppPackageDocument.java b/src/main/java/com/youlai/boot/app/packages/model/document/AppPackageDocument.java new file mode 100644 index 00000000..86ee75bf --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/model/document/AppPackageDocument.java @@ -0,0 +1,122 @@ +package com.youlai.boot.app.packages.model.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用安装包资源库文档(apk / apks / aab / apkm / xapk) + * + * 区别于设备已安装应用记录(ApkInstallDocument),本集合用于管理上传的应用安装包资源, + * 支持从安装包中解析并存储尽可能详细的元数据字段。 + */ +@Schema(description = "应用安装包资源") +@Data +@Builder +@Document(collection = "app_package") +public class AppPackageDocument { + + @Schema(description = "主键ID") + @Id + private String id; + + @Schema(description = "安装包类型:apk / apks / aab / apkm / xapk") + @Field("package_type") + private String packageType; + + @Schema(description = "存储文件名(含后缀)") + @Field("file_name") + private String fileName; + + @Schema(description = "原始上传文件名") + @Field("original_name") + private String originalName; + + @Schema(description = "文件访问URL") + @Field("file_url") + private String fileUrl; + + @Schema(description = "文件大小(字节)") + @Field("file_size") + private Long fileSize; + + @Schema(description = "文件大小(人类可读,如 12.3 MB)") + @Field("file_size_text") + private String fileSizeText; + + @Schema(description = "文件MD5") + @Field("md5") + private String md5; + + @Schema(description = "应用包名(Android applicationId)") + @Field("package_name") + private String packageName; + + @Schema(description = "应用名称/标签") + @Field("app_label") + private String appLabel; + + @Schema(description = "版本名称(versionName)") + @Field("version_name") + private String versionName; + + @Schema(description = "版本号(versionCode)") + @Field("version_code") + private Integer versionCode; + + @Schema(description = "最低SDK版本(minSdkVersion)") + @Field("min_sdk") + private Integer minSdk; + + @Schema(description = "目标SDK版本(targetSdkVersion)") + @Field("target_sdk") + private Integer targetSdk; + + @Schema(description = "编译SDK版本(compileSdkVersion)") + @Field("compile_sdk") + private Integer compileSdk; + + @Schema(description = "支持的CPU架构列表(如 arm64-v8a, armeabi-v7a)") + @Field("abi_list") + private List abiList; + + @Schema(description = "权限列表") + @Field("permissions") + private List permissions; + + @Schema(description = "应用图标URL") + @Field("icon_url") + private String iconUrl; + + @Schema(description = "应用分类ID列表(多选),如 [\"game\", \"tools\"]") + @Field("categories") + private List categories; + + @Schema(description = "应用截图URL列表(与安装包、图标分目录存储)") + @Field("screenshots") + private List screenshots; + + @Schema(description = "是否系统应用") + @Field("system_app") + private Boolean systemApp; + + @Schema(description = "是否可调试(debuggable)") + @Field("debuggable") + private Boolean debuggable; + + @Schema(description = "安装包说明/备注") + @Field("remark") + private String remark; + + @Schema(description = "上传时间") + @CreatedDate + @Field("create_time") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/youlai/boot/app/packages/model/query/AppPackagePageQuery.java b/src/main/java/com/youlai/boot/app/packages/model/query/AppPackagePageQuery.java new file mode 100644 index 00000000..671e8b51 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/model/query/AppPackagePageQuery.java @@ -0,0 +1,18 @@ +package com.youlai.boot.app.packages.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 应用安装包分页查询对象 + */ +@Schema(description = "应用安装包分页查询") +@Data +public class AppPackagePageQuery { + + @Schema(description = "关键字(包名/应用名/文件名模糊匹配)") + private String keywords; + + @Schema(description = "安装包类型过滤:apk / apks / aab / apkm / xapk") + private String packageType; +} diff --git a/src/main/java/com/youlai/boot/app/packages/model/vo/AppPackageVO.java b/src/main/java/com/youlai/boot/app/packages/model/vo/AppPackageVO.java new file mode 100644 index 00000000..cb5f9356 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/model/vo/AppPackageVO.java @@ -0,0 +1,84 @@ +package com.youlai.boot.app.packages.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用安装包资源视图对象 + */ +@Schema(description = "应用安装包资源视图对象") +@Data +public class AppPackageVO { + + @Schema(description = "主键ID") + private String id; + + @Schema(description = "安装包类型:apk / apks / aab / apkm / xapk") + private String packageType; + + @Schema(description = "原始上传文件名") + private String originalName; + + @Schema(description = "文件访问URL") + private String fileUrl; + + @Schema(description = "文件大小(字节)") + private Long fileSize; + + @Schema(description = "文件大小(人类可读)") + private String fileSizeText; + + @Schema(description = "文件MD5") + private String md5; + + @Schema(description = "应用包名") + private String packageName; + + @Schema(description = "应用名称/标签") + private String appLabel; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本号") + private Integer versionCode; + + @Schema(description = "最低SDK版本") + private Integer minSdk; + + @Schema(description = "目标SDK版本") + private Integer targetSdk; + + @Schema(description = "编译SDK版本") + private Integer compileSdk; + + @Schema(description = "支持的CPU架构") + private List abiList; + + @Schema(description = "权限列表") + private List permissions; + + @Schema(description = "应用图标URL") + private String iconUrl; + + @Schema(description = "应用分类ID列表(多选)") + private List categories; + + @Schema(description = "应用截图URL列表") + private List screenshots; + + @Schema(description = "是否系统应用") + private Boolean systemApp; + + @Schema(description = "是否可调试") + private Boolean debuggable; + + @Schema(description = "安装包说明/备注") + private String remark; + + @Schema(description = "上传时间") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java b/src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java new file mode 100644 index 00000000..dd031511 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java @@ -0,0 +1,41 @@ +package com.youlai.boot.app.packages.repository; + +import com.youlai.boot.app.packages.model.document.AppPackageDocument; +import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 应用安装包资源库仓储 + */ +public interface AppPackageRepository { + + /** + * 分页查询安装包列表 + */ + Page page(AppPackagePageQuery query, int pageNum, int pageSize); + + /** + * 根据ID查询 + */ + AppPackageDocument findById(String id); + + /** + * 保存 + */ + AppPackageDocument save(AppPackageDocument document); + + /** + * 根据ID删除 + */ + void deleteById(String id); + + /** + * 更新指定安装包的分类(覆盖写) + * + * @param id 安装包ID + * @param categories 分类编码列表 + */ + void updateCategories(String id, List categories); +} diff --git a/src/main/java/com/youlai/boot/app/packages/repository/impl/AppPackageRepositoryImpl.java b/src/main/java/com/youlai/boot/app/packages/repository/impl/AppPackageRepositoryImpl.java new file mode 100644 index 00000000..7d86c866 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/repository/impl/AppPackageRepositoryImpl.java @@ -0,0 +1,84 @@ +package com.youlai.boot.app.packages.repository.impl; + +import com.youlai.boot.app.packages.model.document.AppPackageDocument; +import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; +import com.youlai.boot.app.packages.repository.AppPackageRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; + +/** + * 应用安装包资源库仓储实现 + */ +@Repository +public class AppPackageRepositoryImpl implements AppPackageRepository { + + private final MongoTemplate mongoTemplate; + + public AppPackageRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public Page page(AppPackagePageQuery query, int pageNum, int pageSize) { + List andCriteria = new ArrayList<>(); + + if (query.getKeywords() != null && !query.getKeywords().isBlank()) { + String kw = query.getKeywords().trim(); + andCriteria.add(new Criteria().orOperator( + Criteria.where("package_name").regex(kw, "i"), + Criteria.where("app_label").regex(kw, "i"), + Criteria.where("original_name").regex(kw, "i"), + Criteria.where("version_name").regex(kw, "i") + )); + } + if (query.getPackageType() != null && !query.getPackageType().isBlank()) { + andCriteria.add(Criteria.where("package_type").is(query.getPackageType().toLowerCase())); + } + + Criteria criteria = new Criteria(); + if (!andCriteria.isEmpty()) { + criteria.andOperator(andCriteria.toArray(new Criteria[0])); + } + + Query pageQuery = new Query(criteria) + .with(Sort.by(Sort.Direction.DESC, "createTime")) + .with(PageRequest.of(pageNum - 1, pageSize)); + List list = mongoTemplate.find(pageQuery, AppPackageDocument.class); + long total = mongoTemplate.count(new Query(criteria), AppPackageDocument.class); + + return new PageImpl<>(list, PageRequest.of(pageNum - 1, pageSize), total); + } + + @Override + public AppPackageDocument findById(String id) { + return mongoTemplate.findById(id, AppPackageDocument.class); + } + + @Override + public AppPackageDocument save(AppPackageDocument document) { + return mongoTemplate.save(document); + } + + @Override + public void deleteById(String id) { + Query query = new Query(Criteria.where("_id").is(id)); + mongoTemplate.remove(query, AppPackageDocument.class); + } + + @Override + public void updateCategories(String id, List categories) { + Query query = new Query(Criteria.where("_id").is(id)); + Update update = new Update().set("categories", categories == null ? List.of() : categories); + mongoTemplate.updateFirst(query, update, AppPackageDocument.class); + } +} diff --git a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java new file mode 100644 index 00000000..b9e899d8 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java @@ -0,0 +1,52 @@ +package com.youlai.boot.app.packages.service; + +import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; +import com.youlai.boot.app.packages.model.vo.AppPackageVO; +import org.springframework.data.domain.Page; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 应用安装包资源库服务 + */ +public interface AppPackageService { + + /** + * 上传安装包(保存文件 + 解析元数据 + 入库) + * + * @param file 安装包文件(apk / apks / aab / apkm / xapk) + * @param packageName 用户补充的包名(apk可自动解析,aab/xapk/apks/apkm建议填写) + * @param versionName 用户补充的版本名称 + * @param versionCode 用户补充的版本号 + * @param remark 备注说明 + * @param categories 应用分类ID列表(多选) + * @param screenshots 应用截图文件列表(与安装包、图标分目录存储) + */ + AppPackageVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String remark, List categories, + MultipartFile[] screenshots); + + /** + * 分页查询 + */ + Page page(AppPackagePageQuery query, int pageNum, int pageSize); + + /** + * 详情 + */ + AppPackageVO detail(String id); + + /** + * 删除(同时删除本地文件) + */ + void removeById(String id); + + /** + * 更新指定安装包的分类(覆盖写) + * + * @param id 安装包ID + * @param categories 分类编码列表 + */ + void updateCategories(String id, List categories); +} diff --git a/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java new file mode 100644 index 00000000..81a9eed0 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java @@ -0,0 +1,254 @@ +package com.youlai.boot.app.packages.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.youlai.boot.app.packages.component.ApkMetaParser; +import com.youlai.boot.app.packages.model.document.AppPackageDocument; +import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; +import com.youlai.boot.app.packages.model.vo.AppPackageVO; +import com.youlai.boot.app.packages.repository.AppPackageRepository; +import com.youlai.boot.app.packages.service.AppPackageService; +import com.youlai.boot.common.config.FilePath; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用安装包资源库服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppPackageServiceImpl implements AppPackageService { + + private final AppPackageRepository appPackageRepository; + private final ApkMetaParser apkMetaParser; + + private static final List ALLOWED_EXT = List.of("apk", "apks", "aab", "apkm", "xapk"); + + @Override + public AppPackageVO upload(MultipartFile file, String packageName, String versionName, + String versionCode, String remark, List categories, + MultipartFile[] screenshots) { + String originalName = file.getOriginalFilename(); + String ext = FileUtil.getSuffix(originalName); + if (ext == null || !ALLOWED_EXT.contains(ext.toLowerCase())) { + throw new IllegalArgumentException("仅支持上传 apk / apks / aab / apkm / xapk 安装包"); + } + + // 1. 落盘安装包文件(内容寻址:md5 作为文件名前缀,避免冲突与重复) + String md5; + File packageDir = new File(FilePath.getPackagePath()); + if (!packageDir.exists()) { + packageDir.mkdirs(); + } + String storedName = IdUtil.simpleUUID() + "." + ext; + File storedFile = new File(packageDir, storedName); + try { + md5 = DigestUtil.md5Hex(file.getInputStream()); + file.transferTo(storedFile); + } catch (IOException e) { + log.error("安装包保存失败", e); + throw new RuntimeException("安装包保存失败"); + } + + long fileSize = storedFile.length(); + String fileUrl = "/static/packages/" + storedName; + + // 2. 解析元数据(apk 解析详细字段;aab/xapk/apks/apkm 仅文件基础信息) + AppPackageDocument.AppPackageDocumentBuilder builder = AppPackageDocument.builder() + .packageType(ext.toLowerCase()) + .fileName(storedName) + .originalName(originalName) + .fileUrl(fileUrl) + .fileSize(fileSize) + .fileSizeText(formatSize(fileSize)) + .md5(md5) + .createTime(LocalDateTime.now()) + .remark(remark); + + // 3. 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储) + List screenshotUrls = saveScreenshots(screenshots); + + if ("apk".equalsIgnoreCase(ext)) { + try { + ApkMetaParser.ApkMetaResult meta = apkMetaParser.parse(storedFile); + builder.packageName(firstNonBlank(meta.getPackageName(), packageName)) + .appLabel(meta.getAppLabel()) + .versionName(firstNonBlank(meta.getVersionName(), versionName)) + .versionCode(orNull(meta.getVersionCode(), versionCode)) + .minSdk(meta.getMinSdk()) + .targetSdk(meta.getTargetSdk()) + .compileSdk(meta.getCompileSdk()) + .abiList(meta.getAbiList()) + .permissions(meta.getPermissions()); + if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) { + String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes()); + String iconName = iconMd5 + ".png"; + File iconDir = new File(FilePath.getApkIconPath()); + if (!iconDir.exists()) { + iconDir.mkdirs(); + } + File iconTarget = new File(iconDir, iconName); + if (!iconTarget.exists()) { + Files.write(iconTarget.toPath(), meta.getIconBytes()); + } + builder.iconUrl("/static/app_icon/" + iconName); + } + } catch (Exception e) { + log.warn("APK 元数据解析失败,使用表单补充信息:{}", e.getMessage()); + fillFallback(builder, packageName, versionName, versionCode); + } + } else { + // aab / xapk / apks / apkm 暂不支持二进制解析,使用表单补充信息 + fillFallback(builder, packageName, versionName, versionCode); + } + + List categoryList = (categories != null) ? categories : List.of(); + builder.categories(categoryList).screenshots(screenshotUrls); + + AppPackageDocument saved = appPackageRepository.save(builder.build()); + return toVO(saved); + } + + /** + * 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储) + */ + private List saveScreenshots(MultipartFile[] screenshots) { + List urls = new java.util.ArrayList<>(); + if (screenshots == null || screenshots.length == 0) { + return urls; + } + File dir = new File(FilePath.getScreenshotPath()); + if (!dir.exists()) { + dir.mkdirs(); + } + List allowedImgExt = List.of("png", "jpg", "jpeg", "webp"); + for (MultipartFile sf : screenshots) { + if (sf == null || sf.isEmpty()) { + continue; + } + String suffix = FileUtil.getSuffix(sf.getOriginalFilename()); + if (suffix == null || !allowedImgExt.contains(suffix.toLowerCase())) { + throw new IllegalArgumentException("应用截图仅支持 png / jpg / jpeg / webp 格式"); + } + String stored = IdUtil.simpleUUID() + "." + suffix; + try { + sf.transferTo(new File(dir, stored)); + } catch (IOException e) { + log.error("应用截图保存失败", e); + throw new RuntimeException("应用截图保存失败"); + } + urls.add("/static/screenshot/" + stored); + } + return urls; + } + + private void fillFallback(AppPackageDocument.AppPackageDocumentBuilder builder, + String packageName, String versionName, String versionCode) { + builder.packageName(packageName) + .versionName(versionName) + .versionCode(parseIntSafe(versionCode)); + } + + @Override + public Page page(AppPackagePageQuery query, int pageNum, int pageSize) { + Page docPage = appPackageRepository.page(query, pageNum, pageSize); + return docPage.map(this::toVO); + } + + @Override + public AppPackageVO detail(String id) { + AppPackageDocument doc = appPackageRepository.findById(id); + return doc == null ? null : toVO(doc); + } + + @Override + public void removeById(String id) { + AppPackageDocument doc = appPackageRepository.findById(id); + if (doc == null) { + return; + } + // 删除本地安装包文件 + if (doc.getFileName() != null) { + FileUtil.del(FilePath.getPackagePath() + doc.getFileName()); + } + // 删除本地图标文件 + if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("/static/app_icon/")) { + String iconName = doc.getIconUrl().substring("/static/app_icon/".length()); + FileUtil.del(FilePath.getApkIconPath() + iconName); + } + // 删除本地截图文件(独立 screenshot 目录) + if (doc.getScreenshots() != null) { + for (String shotUrl : doc.getScreenshots()) { + if (shotUrl != null && shotUrl.startsWith("/static/screenshot/")) { + String shotName = shotUrl.substring("/static/screenshot/".length()); + FileUtil.del(FilePath.getScreenshotPath() + shotName); + } + } + } + appPackageRepository.deleteById(id); + } + + @Override + public void updateCategories(String id, List categories) { + appPackageRepository.updateCategories(id, categories); + } + + private AppPackageVO toVO(AppPackageDocument doc) { + AppPackageVO vo = new AppPackageVO(); + BeanUtils.copyProperties(doc, vo); + return vo; + } + + private String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) { + return v; + } + } + return null; + } + + private Integer orNull(Integer parsed, String fallback) { + if (parsed != null) { + return parsed; + } + return parseIntSafe(fallback); + } + + private Integer parseIntSafe(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private String formatSize(long bytes) { + if (bytes <= 0) { + return "0 B"; + } + String[] units = {"B", "KB", "MB", "GB"}; + int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); + digitGroups = Math.min(digitGroups, units.length - 1); + BigDecimal size = new BigDecimal(bytes) + .divide(new BigDecimal(Math.pow(1024, digitGroups)), 2, RoundingMode.HALF_UP); + return size.toPlainString() + " " + units[digitGroups]; + } +} diff --git a/src/main/java/com/youlai/boot/common/config/FilePath.java b/src/main/java/com/youlai/boot/common/config/FilePath.java index 00617815..8c356222 100644 --- a/src/main/java/com/youlai/boot/common/config/FilePath.java +++ b/src/main/java/com/youlai/boot/common/config/FilePath.java @@ -19,6 +19,8 @@ public class FilePath { public static final String AVATAR_PATH = "avatar"; public static final String APK_ICON_PATH = "apkIcon"; public static final String SCREENSHOT_PATH = "screenshot"; + public static final String PACKAGE_PATH = "packages"; + public static final String CATEGORY_ICON_PATH = "category_icon"; public static String getRootPath() { String osName = System.getProperty("os.name"); @@ -43,4 +45,12 @@ public class FilePath { public static String getScreenshotPath() { return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH + File.separator; } + + public static String getPackagePath() { + return getRootPath() + File.separator + TABLET_PATH + File.separator + PACKAGE_PATH + File.separator; + } + + public static String getCategoryIconPath() { + return getRootPath() + File.separator + TABLET_PATH + File.separator + CATEGORY_ICON_PATH + File.separator; + } } 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 864b101a..f653b2b4 100644 --- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java +++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java @@ -41,5 +41,19 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addResourceHandler("/static/screenshot/**") .addResourceLocations(screenshotLocation) .setCacheControl(CacheControl.noCache()); + + // 应用安装包静态资源映射:/static/packages/{fileName} -> 磁盘 tablet/packages 目录 + // 安装包内容为内容寻址命名(含 md5),内容变更文件名必变,可安全使用长缓存。 + String packageLocation = "file:" + FilePath.getPackagePath().replace("\\", "/"); + registry.addResourceHandler("/static/packages/**") + .addResourceLocations(packageLocation) + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); + + // 分类图标静态资源映射:/static/category_icon/{fileName} -> 磁盘 tablet/category_icon 目录 + // 分类图标为手动上传图片,文件名内容寻址(含 md5),变更必变文件名,可安全使用长缓存。 + String categoryIconLocation = "file:" + FilePath.getCategoryIconPath().replace("\\", "/"); + registry.addResourceHandler("/static/category_icon/**") + .addResourceLocations(categoryIconLocation) + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 1d0dfd4a..dec45643 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -127,7 +127,7 @@ file-storage: # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) - max-file-size: 50MB + max-file-size: 500MB # 允许的文件扩展名白名单(置空表示不限制) allowed-extensions: - jpg diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4c81dcaf..08f16050 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -121,7 +121,7 @@ file-storage: # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) - max-file-size: 50MB + max-file-size: 500MB # 允许的文件扩展名白名单(置空表示不限制) allowed-extensions: - jpg