feat(app): add app category management and package repository with apk parsing

新增应用分类管理(MongoDB CRUD + 图标上传)和应用安装包资源库(上传/分页/详情/删除),集成apk-parser自动解析APK元数据(包名、版本、权限、CPU架构、图标),并添加对应的SQL菜单初始化脚本与授权配置。
This commit is contained in:
TongTongStudio
2026-08-07 12:20:04 +08:00
parent 33fcbcff3e
commit 8ee05af6c7
25 changed files with 1479 additions and 2 deletions

View File

@@ -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<String> 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<AppCategoryVO>> list() {
return Result.success(categoryService.listCategories());
}
@Operation(summary = "分类详情")
@GetMapping("/{id}")
public Result<AppCategoryVO> detail(@PathVariable String id) {
return Result.success(categoryService.getById(id));
}
@Operation(summary = "新增分类")
@PostMapping
public Result<AppCategoryVO> create(@RequestBody AppCategoryForm form) {
return Result.success(categoryService.create(form));
}
@Operation(summary = "更新分类")
@PutMapping("/{id}")
public Result<AppCategoryVO> update(
@Parameter(description = "分类ID") @PathVariable String id,
@RequestBody AppCategoryForm form
) {
return Result.success(categoryService.update(id, form));
}
@Operation(summary = "删除分类")
@DeleteMapping("/{id}")
public Result<Boolean> remove(@PathVariable String id) {
categoryService.remove(id);
return Result.success(true);
}
}

View File

@@ -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
* <p>
* 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<AppCategoryDocument> listAll();
/**
* 根据编码查询(用于唯一性校验)
*/
Optional<AppCategoryDocument> findByCode(String code);
/**
* 根据ID查询
*/
Optional<AppCategoryDocument> findById(String id);
/**
* 保存
*/
AppCategoryDocument save(AppCategoryDocument document);
/**
* 根据ID删除
*/
void deleteById(String id);
}

View File

@@ -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<AppCategoryDocument> 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<AppCategoryDocument> findByCode(String code) {
Query query = new Query(Criteria.where("code").is(code));
return Optional.ofNullable(mongoTemplate.findOne(query, AppCategoryDocument.class));
}
@Override
public Optional<AppCategoryDocument> 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);
}
}

View File

@@ -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<AppCategoryVO> listCategories();
/**
* 分类详情
*/
AppCategoryVO getById(String id);
/**
* 新增分类code 唯一)
*/
AppCategoryVO create(AppCategoryForm form);
/**
* 更新分类code 唯一,排除自身)
*/
AppCategoryVO update(String id, AppCategoryForm form);
/**
* 删除分类
*/
void remove(String id);
}

View File

@@ -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<AppCategoryVO> 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;
}
}

View File

@@ -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
* <p>
* 用于从 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<String> abiList = new ArrayList<>();
private List<String> 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/<abi>/ 目录
result.setAbiList(extractAbiList(apkFile));
// 图标:优先 launcher 图标,取首个图标的字节
try {
List<Icon> 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<String> extractAbiList(File apkFile) {
Set<String> abis = new LinkedHashSet<>();
try (ZipFile zf = new ZipFile(apkFile)) {
java.util.Enumeration<? extends ZipEntry> 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;
}
}
}

View File

@@ -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;
/**
* 应用安装包资源库控制层
* <p>
* 提供 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<AppPackageVO> 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<String> 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<AppPackageVO> 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<AppPackageVO> springPage =
appPackageService.page(query, pageNum, pageSize);
IPage<AppPackageVO> iPage = new Page<>(pageNum, pageSize, springPage.getTotalElements());
iPage.setRecords(springPage.getContent());
return PageResult.success(iPage);
}
@Operation(summary = "安装包详情")
@GetMapping("/{id}")
public Result<AppPackageVO> detail(@PathVariable String id) {
return Result.success(appPackageService.detail(id));
}
@Operation(summary = "删除安装包(同时删除本地文件)")
@DeleteMapping("/{id}")
public Result<Boolean> remove(@PathVariable String id) {
appPackageService.removeById(id);
return Result.success(true);
}
@Operation(summary = "更新安装包分类(覆盖写)")
@PutMapping("/{id}/categories")
public Result<Boolean> updateCategories(
@Parameter(description = "安装包ID") @PathVariable String id,
@Parameter(description = "分类编码列表") @RequestBody List<String> categories
) {
appPackageService.updateCategories(id, categories);
return Result.success(true);
}
}

View File

@@ -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<String> abiList;
@Schema(description = "权限列表")
@Field("permissions")
private List<String> permissions;
@Schema(description = "应用图标URL")
@Field("icon_url")
private String iconUrl;
@Schema(description = "应用分类ID列表多选如 [\"game\", \"tools\"]")
@Field("categories")
private List<String> categories;
@Schema(description = "应用截图URL列表与安装包、图标分目录存储")
@Field("screenshots")
private List<String> 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;
}

View File

@@ -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;
}

View File

@@ -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<String> abiList;
@Schema(description = "权限列表")
private List<String> permissions;
@Schema(description = "应用图标URL")
private String iconUrl;
@Schema(description = "应用分类ID列表多选")
private List<String> categories;
@Schema(description = "应用截图URL列表")
private List<String> screenshots;
@Schema(description = "是否系统应用")
private Boolean systemApp;
@Schema(description = "是否可调试")
private Boolean debuggable;
@Schema(description = "安装包说明/备注")
private String remark;
@Schema(description = "上传时间")
private LocalDateTime createTime;
}

View File

@@ -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<AppPackageDocument> 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<String> categories);
}

View File

@@ -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<AppPackageDocument> page(AppPackagePageQuery query, int pageNum, int pageSize) {
List<Criteria> 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<AppPackageDocument> 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<String> 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);
}
}

View File

@@ -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<String> categories,
MultipartFile[] screenshots);
/**
* 分页查询
*/
Page<AppPackageVO> 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<String> categories);
}

View File

@@ -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<String> ALLOWED_EXT = List.of("apk", "apks", "aab", "apkm", "xapk");
@Override
public AppPackageVO upload(MultipartFile file, String packageName, String versionName,
String versionCode, String remark, List<String> 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<String> 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<String> categoryList = (categories != null) ? categories : List.of();
builder.categories(categoryList).screenshots(screenshotUrls);
AppPackageDocument saved = appPackageRepository.save(builder.build());
return toVO(saved);
}
/**
* 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储)
*/
private List<String> saveScreenshots(MultipartFile[] screenshots) {
List<String> 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<String> 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<AppPackageVO> page(AppPackagePageQuery query, int pageNum, int pageSize) {
Page<AppPackageDocument> 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<String> 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];
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}