feat(app): add app category management and package repository with apk parsing
新增应用分类管理(MongoDB CRUD + 图标上传)和应用安装包资源库(上传/分页/详情/删除),集成apk-parser自动解析APK元数据(包名、版本、权限、CPU架构、图标),并添加对应的SQL菜单初始化脚本与授权配置。
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user