diff --git a/AGENTS.md b/AGENTS.md index 7c148035..e613661c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,20 @@ xxx/ - `/api/v1/client/**`:家属端,仅接受 `clientType=client` 的令牌 - `/api/v1/open/**`:免登录(登录/注册/找回密码) - 后台接口(`/api/v1/device`、`/api/v1/app/**`、`/api/v1/developers` 等):走 Spring Security,按需 `@PreAuthorize` -- 文件落盘统一走 `FilePath`(根目录为 `user.dir` 下的 `uploadFile/tablet/{screenshot,packages,category_icon,wallpaper}`),HTTP 访问走 `/static/{子目录}/**`,由 `WebMvcConfig` 映射;截图/壁纸用 `noCache`,安装包/分类图标用长缓存。 +- 业务文件统一通过 `FileService#uploadFile(MultipartFile, String directory)` 存到 `StorageDir` 定义的固定目录(`app/icon`、`app/package-icon`、`app/package`、`app/screenshot`、`app/avatar`、`app/wallpaper`、`app/category-icon`、`app/file`),实际存储由 `storage.type`(s3 / aliyun / local)决定;**不再按上传日期分目录**。 +- 头像走专用接口 `POST /api/v1/files/avatar`(管理端)与 `POST /api/v1/client/files/avatar`(家属端),落 `app/avatar`;通用上传接口不接受目录参数。 +- `FilePath` + `/static/{子目录}/**` 仅用于设备截图等纯本地资源,以及存量历史文件的访问。 - 新增后台菜单/表结构请补充 `sql/` 脚本,保持与代码同步。 ## 5. 提交约定 采用 Conventional Commits(`feat(device): ...`、`fix(client): ...`、`refactor(config): ...`、`chore: ...`)。涉及上游文件的改动需在正文说明原因。 + +## 6. 不做兼容(强制) + +变更直接定案,**不写向后兼容代码**: + +- 接口入参/出参、字段语义、存储路径变更时,直接改成目标形态;禁止可选参数、双分支、"不传则走旧逻辑"、"新老格式都支持"之类的兜底写法。 +- 禁止为旧前端/旧设备端保留旧接口、旧字段或旧 URL 前缀;需要它们同步修改时,在提交说明中列明改动点。 +- 存储位置由业务语义决定:一类资源对应 `StorageDir` 中的一个固定常量,使用**专用接口**落盘(如 `POST /api/v1/files/avatar`、`POST /api/v1/client/files/avatar`),不得让调用方通过 `directory` 之类的参数决定目录。 +- 存量历史数据(旧本地路径、旧对象目录、旧字段格式)只允许用一次性迁移脚本或管理接口处理,不得在业务代码中长期保留运行时回退分支。 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 index ce3f81ee..47ffa364 100644 --- a/src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java +++ b/src/main/java/com/youlai/boot/app/category/controller/AppCategoryController.java @@ -3,20 +3,19 @@ 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.config.StorageDir; import com.youlai.boot.common.result.Result; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; 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.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.IOException; import java.util.List; /** @@ -26,10 +25,10 @@ import java.util.List; @RestController @RequestMapping("/api/v1/app/category") @RequiredArgsConstructor -@Slf4j public class AppCategoryController { private final AppCategoryService categoryService; + private final FileService fileService; @Operation(summary = "上传分类图标(手动上传图片,返回可访问 URL)") @PostMapping("/icon/upload") @@ -43,27 +42,11 @@ public class AppCategoryController { 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); + FileInfo fileInfo = fileService.uploadFile(file, StorageDir.CATEGORY_ICON); + if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) { return Result.failed("图标保存失败"); } - return Result.success("/static/category_icon/" + stored); + return Result.success(fileInfo.getUrl()); } @Operation(summary = "分类列表(全部,按排序返回)") diff --git a/src/main/java/com/youlai/boot/app/packages/component/FileMultipartFile.java b/src/main/java/com/youlai/boot/app/packages/component/FileMultipartFile.java new file mode 100644 index 00000000..8e65d0e9 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/component/FileMultipartFile.java @@ -0,0 +1,74 @@ +package com.youlai.boot.app.packages.component; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/** + * 基于本地临时文件的 {@link MultipartFile} 实现 + *

+ * 用于把已落盘的安装包(几百 MB 量级)交给 + * {@link com.youlai.boot.support.file.service.FileService} 上传到云存储, + * 与 {@link ByteArrayMultipartFile} 的区别是流式读取、不把内容整体载入内存。 + * + * @author TTSTD + */ +public class FileMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final File file; + + public FileMultipartFile(String name, String originalFilename, String contentType, File file) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.file = file; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return file == null || file.length() == 0; + } + + @Override + public long getSize() { + return file == null ? 0 : file.length(); + } + + @Override + public byte[] getBytes() throws IOException { + return Files.readAllBytes(file.toPath()); + } + + @Override + public InputStream getInputStream() throws IOException { + return new FileInputStream(file); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.copy(file.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + } +} 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 index 45b25eb4..bc2f541d 100644 --- a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java +++ b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java @@ -79,7 +79,7 @@ public class AppPackageController { return Result.success(appPackageService.detail(id)); } - @Operation(summary = "删除安装包(同时删除本地文件)") + @Operation(summary = "删除安装包(同时删除存储文件)") @DeleteMapping("/{id}") public Result remove(@PathVariable String id) { appPackageService.removeById(id); @@ -96,9 +96,15 @@ public class AppPackageController { return Result.success(true); } - @Operation(summary = "下载安装包文件流(文件不存在返回404,不触发错误下载)") + @Operation(summary = "下载安装包(云存储直接重定向,历史本地文件返回文件流)") @GetMapping("/{id}/download") public void download(@Parameter(description = "安装包ID") @PathVariable String id, HttpServletResponse response) throws IOException { + // 云存储:直接重定向到对象访问地址,不占用服务端带宽 + String fileUrl = appPackageService.getFileUrl(id); + if (fileUrl != null && (fileUrl.startsWith("http://") || fileUrl.startsWith("https://"))) { + response.sendRedirect(fileUrl); + return; + } File file = appPackageService.getPackageFile(id); if (file == null) { response.sendError(HttpStatus.NOT_FOUND.value(), "安装包文件不存在"); 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 index dd031511..4888660c 100644 --- a/src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java +++ b/src/main/java/com/youlai/boot/app/packages/repository/AppPackageRepository.java @@ -4,7 +4,9 @@ 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.Collection; import java.util.List; +import java.util.Map; /** * 应用安装包资源库仓储 @@ -38,4 +40,15 @@ public interface AppPackageRepository { * @param categories 分类编码列表 */ void updateCategories(String id, List categories); + + /** + * 批量查询应用包名对应的最新图标地址(取上传时间最新的非空 icon_url) + *

+ * 用于设备已安装应用列表的图标兜底:设备端未上报图标(app_icon 无记录)时, + * 回退到安装包解析出来的图标,避免后台/设备端整片图标为空。 + * + * @param packageNames 应用包名集合 + * @return packageName -> iconUrl + */ + Map findLatestIconUrls(Collection packageNames); } 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 index 7d86c866..ec8ea9c3 100644 --- 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 @@ -14,7 +14,10 @@ import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Repository; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 应用安装包资源库仓储实现 @@ -51,7 +54,8 @@ public class AppPackageRepositoryImpl implements AppPackageRepository { } Query pageQuery = new Query(criteria) - .with(Sort.by(Sort.Direction.DESC, "createTime")) + // 使用文档实际字段名(@Field("create_time")),写属性名会导致排序不生效 + .with(Sort.by(Sort.Direction.DESC, "create_time")) .with(PageRequest.of(pageNum - 1, pageSize)); List list = mongoTemplate.find(pageQuery, AppPackageDocument.class); long total = mongoTemplate.count(new Query(criteria), AppPackageDocument.class); @@ -81,4 +85,24 @@ public class AppPackageRepositoryImpl implements AppPackageRepository { Update update = new Update().set("categories", categories == null ? List.of() : categories); mongoTemplate.updateFirst(query, update, AppPackageDocument.class); } + + @Override + public Map findLatestIconUrls(Collection packageNames) { + if (packageNames == null || packageNames.isEmpty()) { + return Map.of(); + } + Query query = new Query(Criteria.where("package_name").in(packageNames) + .and("icon_url").ne(null).ne("")) + .with(Sort.by(Sort.Direction.DESC, "create_time")); + List documents = mongoTemplate.find(query, AppPackageDocument.class); + + Map urlMap = new HashMap<>(); + for (AppPackageDocument doc : documents) { + if (doc.getPackageName() != null && doc.getIconUrl() != null) { + // 结果按上传时间倒序,首次出现的即该包名的最新图标 + urlMap.putIfAbsent(doc.getPackageName(), doc.getIconUrl()); + } + } + return urlMap; + } } 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 index 098685db..9bd9b2e9 100644 --- a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java +++ b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java @@ -51,7 +51,19 @@ public interface AppPackageService { void updateCategories(String id, List categories); /** - * 下载安装包文件流。文件不存在时返回 null,由 Controller 返回 404。 + * 获取安装包文件访问地址。 + *

+ * 云存储(s3 / aliyun)返回完整 http(s) URL,Controller 可直接重定向; + * 历史本地数据返回 {@code /static/packages/...} 相对路径。 + * + * @param id 安装包ID + * @return 访问地址,不存在返回 null + */ + String getFileUrl(String id); + + /** + * 读取历史本地安装包文件(本地存储时代的数据)。 + * 文件不存在时返回 null,由 Controller 返回 404。 * * @param id 安装包ID * @return 安装包文件,不存在返回 null 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 index 889eedce..5bf3ccef 100644 --- 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 @@ -1,17 +1,19 @@ 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.component.ApkMetaResult; import com.youlai.boot.app.packages.component.ByteArrayMultipartFile; +import com.youlai.boot.app.packages.component.FileMultipartFile; 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 com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.support.file.model.FileInfo; import com.youlai.boot.support.file.service.FileService; import lombok.RequiredArgsConstructor; @@ -19,13 +21,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; /** @@ -52,44 +60,49 @@ public class AppPackageServiceImpl implements AppPackageService { if (ext == null || !ALLOWED_EXT.contains(ext.toLowerCase())) { throw new IllegalArgumentException("仅支持上传 apk / apks / aab / apkm / xapk 安装包"); } + String lowerExt = ext.toLowerCase(); - // 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); + // 1. 上传流先落临时文件:元数据解析需要可随机访问的 File,云存储上传也只能读取一次流 + File tempFile = null; try { - md5 = DigestUtil.md5Hex(file.getInputStream()); - file.transferTo(storedFile); + tempFile = File.createTempFile("app-package-", "." + lowerExt); + try (InputStream in = file.getInputStream()) { + Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } } catch (IOException e) { - log.error("安装包保存失败", e); - throw new RuntimeException("安装包保存失败"); + FileUtil.del(tempFile); + log.error("安装包读取失败", e); + throw new RuntimeException("安装包读取失败"); } - long fileSize = storedFile.length(); - String fileUrl = "/static/packages/" + storedName; + try { + long fileSize = tempFile.length(); + String md5 = DigestUtil.md5Hex(tempFile); - // 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); + // 2. 上传到 storage.type 配置的存储(s3 / aliyun / local),固定落在安装包目录 + FileInfo stored = fileService.uploadFile( + new FileMultipartFile("file", originalName, "application/octet-stream", tempFile), + StorageDir.PACKAGE); + if (stored == null || !StringUtils.hasText(stored.getUrl())) { + throw new BusinessException("安装包上传失败"); + } + String fileUrl = stored.getUrl(); - // 3. 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储) - List screenshotUrls = saveScreenshots(screenshots); + AppPackageDocument.AppPackageDocumentBuilder builder = AppPackageDocument.builder() + .packageType(lowerExt) + .fileName(toFileName(fileUrl)) + .originalName(originalName) + .fileUrl(fileUrl) + .fileSize(fileSize) + .fileSizeText(formatSize(fileSize)) + .md5(md5) + .createTime(LocalDateTime.now()) + .remark(remark); - if ("apk".equalsIgnoreCase(ext)) { + // 3. 解析元数据:apk 解析详细字段;aab / xapk / apks / apkm 解包内部 APK + ApkMetaResult meta = null; try { - ApkMetaResult meta = ApkMetaParser.parse(storedFile); + meta = ApkMetaParser.parse(tempFile); builder.packageName(firstNonBlank(meta.getPackageName(), packageName)) .appLabel(meta.getAppLabel()) .versionName(firstNonBlank(meta.getVersionName(), versionName)) @@ -99,36 +112,29 @@ public class AppPackageServiceImpl implements AppPackageService { .compileSdk(meta.getCompileSdk()) .abiList(meta.getAbiList()) .permissions(meta.getPermissions()); - applyIcon(builder, meta); } catch (Exception e) { - log.warn("APK 元数据解析失败,使用表单补充信息:{}", e.getMessage()); + log.warn("{} 元数据解析失败,使用表单补充信息:{}", lowerExt, e.getMessage()); fillFallback(builder, packageName, versionName, versionCode); } - } else { - // aab / xapk / apks / apkm:统一交由解析器解包内部 APK - try { - 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()); - applyIcon(builder, meta); - } catch (Exception e) { - log.warn("{} 元数据解析失败,使用表单补充信息:{}", ext, e.getMessage()); - fillFallback(builder, packageName, versionName, versionCode); + + // 4. 图标单独上传:失败只丢图标,不阻断安装包入库 + if (meta != null) { + try { + applyIcon(builder, meta); + } catch (Exception e) { + log.warn("{} 图标上传失败,安装包其余信息正常入库:{}", lowerExt, e.getMessage(), e); + } } + + // 5. 应用截图与安装包共用同一存储服务 + builder.categories(categories != null ? categories : List.of()) + .screenshots(saveScreenshots(screenshots)); + + AppPackageDocument saved = appPackageRepository.save(builder.build()); + return toVO(saved); + } finally { + FileUtil.del(tempFile); } - - List categoryList = (categories != null) ? categories : List.of(); - builder.categories(categoryList).screenshots(screenshotUrls); - - AppPackageDocument saved = appPackageRepository.save(builder.build()); - return toVO(saved); } /** @@ -140,22 +146,21 @@ public class AppPackageServiceImpl implements AppPackageService { } String iconName = DigestUtil.md5Hex(meta.getIconBytes()) + ".png"; FileInfo iconInfo = fileService.uploadFile( - new ByteArrayMultipartFile("file", iconName, "image/png", meta.getIconBytes())); + new ByteArrayMultipartFile("file", iconName, "image/png", meta.getIconBytes()), StorageDir.PACKAGE_ICON); + if (iconInfo == null || !StringUtils.hasText(iconInfo.getUrl())) { + throw new BusinessException("安装包图标上传失败"); + } builder.iconUrl(iconInfo.getUrl()); } /** - * 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储) + * 上传应用截图:与安装包、图标共用同一存储服务(storage.type:s3 / aliyun / local) */ private List saveScreenshots(MultipartFile[] screenshots) { - List urls = new java.util.ArrayList<>(); + List urls = new 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()) { @@ -165,14 +170,11 @@ public class AppPackageServiceImpl implements AppPackageService { 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("应用截图保存失败"); + FileInfo info = fileService.uploadFile(sf, StorageDir.SCREENSHOT); + if (info == null || !StringUtils.hasText(info.getUrl())) { + throw new BusinessException("应用截图上传失败"); } - urls.add("/static/screenshot/" + stored); + urls.add(info.getUrl()); } return urls; } @@ -202,22 +204,25 @@ public class AppPackageServiceImpl implements AppPackageService { if (doc == null) { return; } - // 删除本地安装包文件 - if (doc.getFileName() != null) { + // 删除安装包对象:云端(http URL)走存储服务,历史本地文件(/static/packages/)直接删磁盘 + if (isRemoteUrl(doc.getFileUrl())) { + deleteRemoteQuietly(doc.getFileUrl(), "安装包"); + } else if (doc.getFileName() != null) { FileUtil.del(FilePath.getPackagePath() + doc.getFileName()); } - // 删除云端图标对象(历史 /static 本地图标已下线索引,不再处理) - if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("http")) { - try { - fileService.deleteFile(doc.getIconUrl()); - } catch (Exception e) { - log.warn("删除云端图标失败:{}", doc.getIconUrl(), e); - } + // 删除图标对象:云端走存储服务,历史 /static 本地图标已下线映射,仅清理磁盘文件 + if (isRemoteUrl(doc.getIconUrl())) { + deleteRemoteQuietly(doc.getIconUrl(), "图标"); + } else 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/")) { + if (isRemoteUrl(shotUrl)) { + deleteRemoteQuietly(shotUrl, "截图"); + } else if (shotUrl != null && shotUrl.startsWith("/static/screenshot/")) { String shotName = shotUrl.substring("/static/screenshot/".length()); FileUtil.del(FilePath.getScreenshotPath() + shotName); } @@ -253,6 +258,47 @@ public class AppPackageServiceImpl implements AppPackageService { return doc.getOriginalName(); } + @Override + public String getFileUrl(String id) { + AppPackageDocument doc = appPackageRepository.findById(id); + return doc == null ? null : doc.getFileUrl(); + } + + /** + * 从访问地址中取出纯文件名(保持 fileName 字段语义,历史数据同样为纯文件名) + */ + private String toFileName(String url) { + if (!StringUtils.hasText(url)) { + return null; + } + String path = url; + try { + String uriPath = URI.create(url).getPath(); + if (StringUtils.hasText(uriPath)) { + path = uriPath; + } + } catch (IllegalArgumentException ignored) { + // 非标准 URL(历史本地相对路径)按原串处理 + } + int slash = path.lastIndexOf('/'); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + private boolean isRemoteUrl(String url) { + return url != null && (url.startsWith("http://") || url.startsWith("https://")); + } + + /** + * 删除云存储对象:失败只记日志,不阻断数据库记录删除 + */ + private void deleteRemoteQuietly(String url, String scene) { + try { + fileService.deleteFile(url); + } catch (Exception e) { + log.warn("删除云端{}失败:{}", scene, url, e); + } + } + private AppPackageVO toVO(AppPackageDocument doc) { AppPackageVO vo = new AppPackageVO(); BeanUtils.copyProperties(doc, vo); diff --git a/src/main/java/com/youlai/boot/client/controller/ClientController.java b/src/main/java/com/youlai/boot/client/controller/ClientController.java index 9a685e02..f921d96f 100644 --- a/src/main/java/com/youlai/boot/client/controller/ClientController.java +++ b/src/main/java/com/youlai/boot/client/controller/ClientController.java @@ -3,7 +3,7 @@ package com.youlai.boot.client.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.youlai.boot.client.model.entity.ClientUser; import com.youlai.boot.client.service.ClientUserService; -import com.youlai.boot.common.config.FilePath; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.util.HashUtils; @@ -53,7 +53,9 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.apache.commons.io.FilenameUtils; -import java.io.File; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; + import java.util.ArrayList; import java.util.List; @@ -83,6 +85,7 @@ public class ClientController { private final DeviceWallpaperService deviceWallpaperService; private final DevicePictureService devicePictureService; private final DeviceAlarmService deviceAlarmService; + private final FileService fileService; /** * 校验当前登录的 client 用户是否绑定了传入的设备 SN。 @@ -555,7 +558,9 @@ public class ClientController { if (savedFile == null) { return Result.failed("图片保存失败"); } - String imageUrl = "/static/wallpaper/" + savedFile.getFileName(); + String imageUrl = StringUtils.hasText(savedFile.getUrl()) + ? savedFile.getUrl() + : "/static/wallpaper/" + savedFile.getFileName(); boolean sent = devicePictureService.sendPicture(sn, imageUrl, favorite != null && favorite == 1, savedFile.getFileSize()); if (!sent) { return Result.failed("图片已保存,但推送设备失败,请检查设备是否在线"); @@ -703,42 +708,38 @@ public class ClientController { } /** - * 将壁纸图片保存到磁盘壁纸目录,返回文件名与哈希信息;失败返回 null。 - *

文件名规则:{sn}_{纳秒时间戳}_{md5}.{ext},可经 /static/wallpaper/{fileName} 访问。 - * 使用 {@code System.nanoTime()} 而非毫秒时间戳,避免同一毫秒上传相同图片时 - * 文件名(进而 URL)重复,造成壁纸明细表出现同 URL 多行、排序 sort_no 重复。 + * 将壁纸图片上传到配置的存储(storage.type:s3 / aliyun / local),固定落在壁纸目录。 + *

对象名由存储服务生成(UUID),URL 天然唯一,避免壁纸明细表出现同 URL 多行、 + * 排序 sort_no 重复的问题。 *

同时计算并返回 md5/sha1/sha256,供壁纸明细表落库。 + * + * @return 文件信息(含完整 URL 与哈希),失败返回 null */ private WallpaperFileDTO saveWallpaperFile(String sn, MultipartFile file) { try { - String wallpaperPath = FilePath.getWallpaperPath(); - File fileDir = new File(wallpaperPath); - if (!fileDir.exists() && !fileDir.mkdirs()) { - log.error("创建壁纸目录失败: {}", wallpaperPath); - return null; - } String originName = file.getOriginalFilename(); if (originName == null || originName.isEmpty()) { return null; } // 清洗文件名,去除路径穿越风险(只保留纯文件名) - String safeBaseName = FilenameUtils.getName(originName); - if (safeBaseName.isEmpty()) { + if (!StringUtils.hasText(FilenameUtils.getName(originName))) { return null; } - String fileExtension = FilenameUtils.getExtension(safeBaseName); - if (fileExtension == null || fileExtension.isEmpty()) { - fileExtension = "jpg"; - } String md5 = HashUtils.calculateMultipartFileMd5(file); String sha1 = HashUtils.calculateMultipartFileSha1(file); String sha256 = HashUtils.calculateMultipartFileSha256(file); - String fileName = sn + "_" + System.nanoTime() + "_" + md5 + "." + fileExtension; - File destFile = new File(fileDir, fileName); - file.transferTo(destFile); + + FileInfo stored = fileService.uploadFile(file, StorageDir.WALLPAPER); + if (stored == null || !StringUtils.hasText(stored.getUrl())) { + log.error("壁纸上传失败, sn: {}", sn); + return null; + } + String url = stored.getUrl(); + int slash = url.lastIndexOf('/'); WallpaperFileDTO dto = new WallpaperFileDTO(); - dto.setFileName(fileName); + dto.setFileName(slash >= 0 ? url.substring(slash + 1) : url); + dto.setUrl(url); dto.setFileSize(file.getSize()); dto.setMd5(md5); dto.setSha1(sha1); diff --git a/src/main/java/com/youlai/boot/client/controller/ClientFileController.java b/src/main/java/com/youlai/boot/client/controller/ClientFileController.java index 4b4ac042..fadd0e71 100644 --- a/src/main/java/com/youlai/boot/client/controller/ClientFileController.java +++ b/src/main/java/com/youlai/boot/client/controller/ClientFileController.java @@ -2,6 +2,7 @@ package com.youlai.boot.client.controller; import com.youlai.boot.client.model.vo.ClientFileInfo; import com.youlai.boot.client.service.ClientFileService; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.result.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -41,8 +42,23 @@ public class ClientFileController { @RequestPart(value = "file") MultipartFile file ) { Assert.isTrue(!file.isEmpty(), "上传文件不能为空文件"); - ClientFileInfo fileInfo = fileService.uploadFile(file); - return Result.success(fileInfo); + return Result.success(fileService.uploadFile(file, StorageDir.FILE)); + } + + @PostMapping("/avatar") + @Operation(summary = "上传用户头像(固定 app/avatar 目录)") + public Result uploadAvatar( + @Parameter( + name = "file", + description = "头像图片", + required = true, + in = ParameterIn.DEFAULT, + schema = @Schema(name = "file", format = "binary") + ) + @RequestPart(value = "file") MultipartFile file + ) { + Assert.isTrue(!file.isEmpty(), "上传文件不能为空文件"); + return Result.success(fileService.uploadFile(file, StorageDir.AVATAR)); } @DeleteMapping diff --git a/src/main/java/com/youlai/boot/client/service/ClientFileService.java b/src/main/java/com/youlai/boot/client/service/ClientFileService.java index 46740e08..0330ae7a 100644 --- a/src/main/java/com/youlai/boot/client/service/ClientFileService.java +++ b/src/main/java/com/youlai/boot/client/service/ClientFileService.java @@ -18,6 +18,15 @@ public interface ClientFileService { */ ClientFileInfo uploadFile(MultipartFile file); + /** + * 上传文件到指定业务目录(如头像 {@code app/avatar}) + * + * @param file 表单文件对象 + * @param directory 业务目录,为空时使用存储默认策略 + * @return 文件信息 + */ + ClientFileInfo uploadFile(MultipartFile file, String directory); + /** * 删除文件 * diff --git a/src/main/java/com/youlai/boot/client/service/impl/ClientClientFileService.java b/src/main/java/com/youlai/boot/client/service/impl/ClientClientFileService.java index ba9a6e05..e5ca09dd 100644 --- a/src/main/java/com/youlai/boot/client/service/impl/ClientClientFileService.java +++ b/src/main/java/com/youlai/boot/client/service/impl/ClientClientFileService.java @@ -1,58 +1,50 @@ package com.youlai.boot.client.service.impl; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.IdUtil; import com.youlai.boot.client.model.vo.ClientFileInfo; -import com.youlai.boot.common.util.HashUtils; import com.youlai.boot.client.service.ClientFileService; -import lombok.Data; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.util.HashUtils; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.InputStream; -import java.time.LocalDateTime; - /** - * 本地存储服务类 + * 家属端文件存储实现:统一委托 {@link FileService}, + * 由 {@code storage.type}(s3 / aliyun / local)决定落地位置,支持按业务目录存放。 * * @author Theo * @since 2024-12-09 17:11 */ -@Data @Slf4j @Component -//@ConditionalOnProperty(value = "oss.type", havingValue = "local") -//@ConfigurationProperties(prefix = "oss.local") @RequiredArgsConstructor public class ClientClientFileService implements ClientFileService { - @Value("${storage.local.path}") - private String storagePath; + private final FileService fileService; - /** - * 上传文件方法 - * - * @param file 表单文件对象 - * @return 文件信息 - */ @Override public ClientFileInfo uploadFile(MultipartFile file) { - // 获取文件名 + return uploadFile(file, StorageDir.FILE); + } + + /** + * 上传文件并附带内容哈希(家属端上传结果包含 md5/sha1/sha256) + */ + @Override + public ClientFileInfo uploadFile(MultipartFile file, String directory) { String originalFilename = file.getOriginalFilename(); - // 获取文件后缀 String suffix = FileUtil.getSuffix(originalFilename); - // 生成uuid - String fileName = IdUtil.simpleUUID()+ "." + suffix;; - // 生成文件名(日期文件夹) - String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); - String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator; - // 计算文件哈希(md5/sha1/sha256) + if (!StringUtils.hasText(suffix)) { + throw new BusinessException("文件缺少扩展名,无法上传"); + } + String md5; String sha1; String sha256; @@ -62,45 +54,31 @@ public class ClientClientFileService implements ClientFileService { sha256 = HashUtils.calculateMultipartFileSha256(file); } catch (Exception e) { log.error("计算文件哈希失败", e); - throw new RuntimeException("文件上传失败"); + throw new BusinessException("文件上传失败"); } - // try-with-resource 语法糖自动释放流 - try (InputStream inputStream = file.getInputStream()) { - // 上传文件 - FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName); - } catch (Exception e) { - log.error("文件上传失败", e); - throw new RuntimeException("文件上传失败"); + + FileInfo stored = fileService.uploadFile(file, directory); + if (stored == null || !StringUtils.hasText(stored.getUrl())) { + throw new BusinessException("文件上传失败"); } - // 获取文件访问路径,因为这里是本地存储,所以直接返回文件的相对路径,需要前端自行处理访问前缀 - String fileUrl = File.separator + folder + File.separator + fileName; + ClientFileInfo fileInfo = new ClientFileInfo(); - fileInfo.setName(originalFilename); - fileInfo.setUrl(fileUrl); + fileInfo.setName(StringUtils.hasText(originalFilename) ? originalFilename : IdUtil.simpleUUID() + "." + suffix); + fileInfo.setUrl(stored.getUrl()); fileInfo.setMd5(md5); fileInfo.setSha1(sha1); fileInfo.setSha256(sha256); return fileInfo; } - /** - * 删除文件 - * @param filePath 文件完整URL - * @return 是否删除成功 + * 删除文件(完整 URL) */ @Override public boolean deleteFile(String filePath) { - //判断文件是否为空 - if (filePath == null || filePath.isEmpty()) { + if (!StringUtils.hasText(filePath)) { return false; } - // 判断filepath是否为文件夹 - if (FileUtil.isDirectory(storagePath + filePath)) { - // 禁止删除文件夹 - return false; - } - // 删除文件 - return FileUtil.del(storagePath + filePath); + return fileService.deleteFile(filePath); } } 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 6d8290cb..4a2202fc 100644 --- a/src/main/java/com/youlai/boot/common/config/FilePath.java +++ b/src/main/java/com/youlai/boot/common/config/FilePath.java @@ -21,6 +21,7 @@ public class FilePath { public static final String TABLET_PATH = "tablet"; 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 WALLPAPER_PATH = "wallpaper"; public static final String PACKAGE_PATH = "packages"; @@ -42,6 +43,13 @@ public class FilePath { return getRootPath() + File.separator + TABLET_PATH + File.separator + AVATAR_PATH + File.separator; } + /** + * 历史应用图标目录(旧版本按 md5 落盘,现仅用于兼容存量 icon_url 与清理) + */ + public static String getApkIconPath() { + return getRootPath() + File.separator + TABLET_PATH + File.separator + APK_ICON_PATH + File.separator; + } + public static String getScreenshotPath() { return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH + File.separator; } diff --git a/src/main/java/com/youlai/boot/common/config/StorageDir.java b/src/main/java/com/youlai/boot/common/config/StorageDir.java new file mode 100644 index 00000000..2c898908 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/config/StorageDir.java @@ -0,0 +1,38 @@ +package com.youlai.boot.common.config; + +/** + * 对象存储业务目录(固定目录,不按日期滚动) + *

+ * 各类资源落在独立目录,便于按目录配置读权限、生命周期与 CDN 缓存规则; + * 同一类资源的 URL 前缀固定,也便于运维批量迁移与清理。 + *

+ * 约定:目录随业务类型固定,不提供运行时可变目录参数,调用方直接使用这里的常量。 + * + * @author TTSTD + */ +public interface StorageDir { + + /** 应用图标:设备端上报 / 后台手动上传(图标库) */ + String ICON = "app/icon"; + + /** 安装包解析提取的图标(与设备/后台上传的图标分开放) */ + String PACKAGE_ICON = "app/package-icon"; + + /** 应用安装包(apk / apks / aab / apkm / xapk) */ + String PACKAGE = "app/package"; + + /** 安装包应用截图 */ + String SCREENSHOT = "app/screenshot"; + + /** 用户头像(管理端 / 家属端) */ + String AVATAR = "app/avatar"; + + /** 设备壁纸与发送到设备的图片 */ + String WALLPAPER = "app/wallpaper"; + + /** 应用分类图标 */ + String CATEGORY_ICON = "app/category-icon"; + + /** 通用业务文件(未归类的上传,如富文本图片) */ + String FILE = "app/file"; +} 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 82b33e73..9c5c407f 100644 --- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java +++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java @@ -17,10 +17,18 @@ import java.util.concurrent.TimeUnit; public class WebMvcConfig implements WebMvcConfigurer { /** - * 静态资源映射(应用图标已改为云端存储,不再本地映射) + * 静态资源映射 */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 历史应用图标静态资源映射:/static/app_icon/{md5}.png -> 磁盘 tablet/apkIcon 目录 + // 新上传图标已改存云存储,此处仅为兼容存量 app_icon / app_package 记录中的相对地址, + // 避免映射缺失导致旧图标整片 404。文件名为内容 MD5,可安全使用长缓存。 + String legacyIconLocation = "file:" + FilePath.getApkIconPath().replace("\\", "/"); + registry.addResourceHandler("/static/app_icon/**") + .addResourceLocations(legacyIconLocation) + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); + // 设备截图静态资源映射:/static/screenshot/{fileName} -> 磁盘 tablet/screenshot 目录 // 截图内容会变化(删除/重截),且文件名 md5 可能因画面相同而重复, // 故不使用长缓存,避免浏览器复用旧图(表现为 304 + 图片不更新)。 diff --git a/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java b/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java index dbe4f537..d018b0f2 100644 --- a/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java +++ b/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java @@ -12,10 +12,15 @@ import lombok.Data; public class WallpaperFileDTO { /** - * 保存后的纯文件名(可经 /static/wallpaper/{fileName} 访问) + * 保存后的纯文件名(历史本地存储时用于拼接 /static/wallpaper/{fileName}) */ private String fileName; + /** + * 文件访问地址(云存储为完整 URL,历史数据为 /static/wallpaper/{fileName}) + */ + private String url; + /** * 文件大小(字节) */ diff --git a/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java index fd0bf49a..1ebea962 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/ApkInstallServiceImpl.java @@ -1,5 +1,6 @@ package com.youlai.boot.device.service.impl; +import com.youlai.boot.app.packages.repository.AppPackageRepository; import com.youlai.boot.device.model.document.ApkInstallDocument; import com.youlai.boot.device.model.req.ApkInstallInfoReq; import com.youlai.boot.device.model.vo.DeviceApkInfoVO; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,6 +28,7 @@ public class ApkInstallServiceImpl implements ApkInstallService { private final ApkInstallRepository apkInstallRepository; private final AppIconService appIconService; + private final AppPackageRepository appPackageRepository; @Override public boolean saveOrUpdateDeviceApkInfo(String sn, List apkInfos) { @@ -85,11 +88,14 @@ public class ApkInstallServiceImpl implements ApkInstallService { .filter(p -> p != null && !p.isBlank()) .toList(); Map activeIconUrls = appIconService.getActiveIconUrls(packageNames); + // 兜底:图标库无记录(设备端未上报)时,回退到安装包解析出的图标,避免列表整片无图标 + Map packageIconUrls = new HashMap<>(appPackageRepository.findLatestIconUrls(packageNames)); + packageIconUrls.putAll(activeIconUrls); return apkInfoList.stream().map(apkInfo -> { DeviceApkInfoVO vo = new DeviceApkInfoVO(); BeanUtils.copyProperties(apkInfo, vo); - vo.setIconUrl(activeIconUrls.get(apkInfo.getPackageName())); + vo.setIconUrl(packageIconUrls.get(apkInfo.getPackageName())); if (apkInfo.getInstallTime() != null) { vo.setInstallTime(Date.from(apkInfo.getInstallTime().atZone(ZoneId.systemDefault()).toInstant())); diff --git a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java index 63b8e637..c1b852ba 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.util.HashUtils; @@ -318,7 +319,7 @@ public class AppIconServiceImpl extends ServiceImpl impl * local 为相对存储根目录的路径,前端拼访问前缀。 */ private String saveIconFile(MultipartFile file) { - FileInfo fileInfo = fileService.uploadFile(file); + FileInfo fileInfo = fileService.uploadFile(file, StorageDir.ICON); if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) { throw new BusinessException("保存应用图标失败"); } diff --git a/src/main/java/com/youlai/boot/device/service/impl/DeviceWallpaperServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DeviceWallpaperServiceImpl.java index 99c3edc1..1d8953de 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/DeviceWallpaperServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/DeviceWallpaperServiceImpl.java @@ -92,10 +92,13 @@ public class DeviceWallpaperServiceImpl extends ServiceImpl items = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { WallpaperFileDTO file = files.get(i); - if (file == null || StringUtils.isBlank(file.getFileName())) { + if (file == null || (StringUtils.isBlank(file.getUrl()) && StringUtils.isBlank(file.getFileName()))) { continue; } - String url = prefix + "/wallpaper/" + file.getFileName(); + // 云存储直接使用存储服务返回的完整 URL;历史本地存储按静态资源前缀拼接 + String url = StringUtils.isNotBlank(file.getUrl()) + ? file.getUrl() + : prefix + "/wallpaper/" + file.getFileName(); urls.add(url); SnDeviceWallpaperItem item = new SnDeviceWallpaperItem(); item.setSerialno(sn); diff --git a/src/main/java/com/youlai/boot/support/file/service/FileService.java b/src/main/java/com/youlai/boot/support/file/service/FileService.java index 72cae226..6f8abba0 100644 --- a/src/main/java/com/youlai/boot/support/file/service/FileService.java +++ b/src/main/java/com/youlai/boot/support/file/service/FileService.java @@ -12,13 +12,22 @@ import org.springframework.web.multipart.MultipartFile; public interface FileService { /** - * 上传文件 + * 上传通用业务文件(固定落在 {@code app/file} 目录) * * @param file 表单文件对象 * @return 文件信息 */ FileInfo uploadFile(MultipartFile file); + /** + * 上传文件到指定业务目录(固定目录,便于按目录配置权限、生命周期与 CDN 规则) + * + * @param file 表单文件对象 + * @param directory 业务目录,如 {@code app/icon}(见 {@code StorageDir}) + * @return 文件信息 + */ + FileInfo uploadFile(MultipartFile file, String directory); + /** * 删除文件 * diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java index ce084cd7..127afc8c 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java @@ -1,6 +1,5 @@ package com.youlai.boot.support.file.service.impl; -import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.IdUtil; @@ -9,6 +8,7 @@ import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.model.ObjectMetadata; import com.aliyun.oss.model.PutObjectRequest; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.support.file.model.FileInfo; import com.youlai.boot.support.file.service.FileService; @@ -23,7 +23,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; -import java.time.LocalDateTime; +import java.net.URI; /** * 阿里云 OSS 文件存储服务。 @@ -44,6 +44,8 @@ public class AliyunFileServiceImpl implements FileService { private String accessKeySecret; // 存储空间名称 private String bucket; + // 自定义访问域名(可选,如 CDN / 反向代理):配置后文件 URL 走该域名,留空则用 bucket.endpoint + private String domain; private OSS aliyunOssClient; @@ -53,31 +55,52 @@ public class AliyunFileServiceImpl implements FileService { } /** - * 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL。 + * 上传通用业务文件到 {@code app/file} 目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL。 */ @Override @SneakyThrows public FileInfo uploadFile(MultipartFile file) { + return uploadFile(file, StorageDir.FILE); + } + + /** + * 上传文件到指定业务目录(固定目录)。 + */ + @Override + @SneakyThrows + public FileInfo uploadFile(MultipartFile file, String directory) { String originalFilename = file.getOriginalFilename(); String suffix = FileUtil.getSuffix(originalFilename); - String uuid = IdUtil.simpleUUID(); - String fileName = DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uuid + "." + suffix; + String fileName = buildObjectKey(directory, IdUtil.simpleUUID(), suffix); try (InputStream inputStream = file.getInputStream()) { ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(file.getContentType()); + // 显式声明长度:安装包等大文件按流上传时,SDK 依赖该值分块读取,缺失会退化为整体缓存到内存 + metadata.setContentLength(file.getSize()); PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, fileName, inputStream, metadata); aliyunOssClient.putObject(putObjectRequest); } catch (Exception e) { log.error("文件上传失败", e); throw new BusinessException("文件上传失败", e); } - String fileUrl = getFileHost() + "/" + fileName; + String fileUrl = StrUtil.isNotBlank(domain) + ? StrUtil.removeSuffix(StrUtil.trim(domain), "/") + "/" + fileName + : getFileHost() + "/" + fileName; FileInfo fileInfo = new FileInfo(); fileInfo.setName(originalFilename); fileInfo.setUrl(fileUrl); return fileInfo; } + /** + * 生成对象 key:{业务目录}/{uuid}.{后缀} + */ + private String buildObjectKey(String directory, String uuid, String suffix) { + Assert.notBlank(directory, "存储目录不能为空"); + String name = StrUtil.isBlank(suffix) ? uuid : uuid + "." + suffix; + return StrUtil.strip(StrUtil.trim(directory), "/") + "/" + name; + } + /** * 从完整 URL 中提取文件名后调用 OSS 删除。 * @@ -87,11 +110,27 @@ public class AliyunFileServiceImpl implements FileService { @Override public boolean deleteFile(String filePath) { Assert.notBlank(filePath, "删除文件路径不能为空"); - String fileName = filePath.substring(getFileHost().length() + 1); + String fileName = resolveObjectKey(filePath); aliyunOssClient.deleteObject(bucket, fileName); return true; } + /** + * 从访问地址解析对象 key:优先按 URL 路径解析(兼容自定义域名与签名参数), + * 非标准地址退化为按访问主机前缀截取。 + */ + private String resolveObjectKey(String filePath) { + try { + String path = URI.create(filePath).getPath(); + if (StrUtil.isNotBlank(path)) { + return path.startsWith("/") ? path.substring(1) : path; + } + } catch (IllegalArgumentException ignored) { + // 非标准 URL,走主机前缀截取 + } + return filePath.substring(getFileHost().length() + 1); + } + /** * 补全 endpoint 协议前缀:已带 {@code http://} 或 {@code https://} 的仅去除尾部 {@code /}, * 纯域名写法默认补 {@code https://},避免 OSS 客户端对无协议 endpoint 默认走 http 明文传输。 diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java index 99d0ee95..8b63da00 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/LocalFileServiceImpl.java @@ -1,9 +1,10 @@ package com.youlai.boot.support.file.service.impl; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.util.HashUtils; import com.youlai.boot.support.file.model.FileInfo; @@ -18,7 +19,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.InputStream; -import java.time.LocalDateTime; /** * 本地文件存储服务。 @@ -35,14 +35,23 @@ public class LocalFileServiceImpl implements FileService { private String path; /** - * 上传文件到本地磁盘,按日期分目录,返回相对于存储根目录的路径。 + * 上传通用业务文件到 {@code app/file} 目录,返回相对于存储根目录的路径。 */ @Override public FileInfo uploadFile(MultipartFile file) { + return uploadFile(file, StorageDir.FILE); + } + + /** + * 上传文件到本地磁盘的指定业务目录。 + */ + @Override + public FileInfo uploadFile(MultipartFile file, String directory) { String originalFilename = file.getOriginalFilename(); String suffix = FileUtil.getSuffix(originalFilename); - String fileName = IdUtil.simpleUUID() + "." + suffix; - String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); + Assert.notBlank(directory, "存储目录不能为空"); + String fileName = StrUtil.isBlank(suffix) ? IdUtil.simpleUUID() : IdUtil.simpleUUID() + "." + suffix; + String folder = StrUtil.strip(StrUtil.trim(directory), "/").replace("/", File.separator); String filePrefix = path.endsWith(File.separator) ? path : path + File.separator; // 计算文件哈希(md5/sha1/sha256) String md5; diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java index ad1cdd70..af2810c8 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/S3FileServiceImpl.java @@ -1,10 +1,10 @@ package com.youlai.boot.support.file.service.impl; -import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.support.file.model.FileInfo; @@ -36,7 +36,6 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignReques import java.net.URI; import java.time.Duration; -import java.time.LocalDateTime; /** * S3 协议文件存储实现类(当前部署为 RustFS) @@ -95,17 +94,22 @@ public class S3FileServiceImpl implements FileService { @Override @SneakyThrows public FileInfo uploadFile(MultipartFile file) { + return uploadFile(file, StorageDir.FILE); + } + + @Override + @SneakyThrows + public FileInfo uploadFile(MultipartFile file, String directory) { createBucketIfAbsent(bucket); String originalFilename = file.getOriginalFilename(); String suffix = FileUtil.getSuffix(originalFilename); - String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd"); - String fileName = IdUtil.simpleUUID() + "." + suffix; + String key = buildObjectKey(directory, IdUtil.simpleUUID(), suffix); try { byte[] bytes = file.getBytes(); PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucket) - .key(dateFolder + "/" + fileName) + .key(key) .contentType(file.getContentType()) .build(); // 用固定长度的字节数组上传,避免流式分块签名在某些 S3 兼容服务上报校验和不匹配 @@ -113,9 +117,9 @@ public class S3FileServiceImpl implements FileService { String fileUrl; if (StrUtil.isBlank(domain)) { - fileUrl = presignedUrl(dateFolder, fileName); + fileUrl = presignedUrl(key); } else { - fileUrl = domain + "/" + bucket + "/" + dateFolder + "/" + fileName; + fileUrl = domain + "/" + bucket + "/" + key; } FileInfo fileInfo = new FileInfo(); @@ -128,14 +132,23 @@ public class S3FileServiceImpl implements FileService { } } + /** + * 生成对象 key:{业务目录}/{uuid}.{后缀} + */ + private String buildObjectKey(String directory, String uuid, String suffix) { + Assert.notBlank(directory, "存储目录不能为空"); + String name = StrUtil.isBlank(suffix) ? uuid : uuid + "." + suffix; + return StrUtil.strip(StrUtil.trim(directory), "/") + "/" + name; + } + /** * 生成预签名 GET URL 并去掉 query 参数,路径直达对象。 * 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用。 */ - private String presignedUrl(String dateFolder, String fileName) { + private String presignedUrl(String key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) - .key(dateFolder + "/" + fileName) + .key(key) .build(); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(10)) diff --git a/src/main/java/com/youlai/boot/system/controller/FileController.java b/src/main/java/com/youlai/boot/system/controller/FileController.java index fdaff799..0eb4bef2 100644 --- a/src/main/java/com/youlai/boot/system/controller/FileController.java +++ b/src/main/java/com/youlai/boot/system/controller/FileController.java @@ -2,6 +2,7 @@ package com.youlai.boot.system.controller; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.ResultCode; @@ -32,6 +33,9 @@ import java.util.Set; @RequiredArgsConstructor public class FileController { + /** 头像允许的图片格式 */ + private static final Set AVATAR_EXTENSIONS = Set.of("png", "jpg", "jpeg", "webp", "gif"); + private final FileService fileService; private final StorageProperties storageProperties; @@ -48,8 +52,34 @@ public class FileController { @RequestPart(value = "file") MultipartFile file ) { validateFileExtension(file); - FileInfo fileInfo = fileService.uploadFile(file); - return Result.success(fileInfo); + return Result.success(fileService.uploadFile(file, StorageDir.FILE)); + } + + @PostMapping("/avatar") + @Operation(summary = "上传用户头像(固定 app/avatar 目录)") + public Result uploadAvatar( + @Parameter( + name = "file", + description = "头像图片", + required = true, + in = ParameterIn.DEFAULT, + schema = @Schema(name = "file", format = "binary") + ) + @RequestPart(value = "file") MultipartFile file + ) { + validateFileExtension(file); + validateAvatarExtension(file); + return Result.success(fileService.uploadFile(file, StorageDir.AVATAR)); + } + + /** + * 头像仅允许图片类型,避免通过头像接口上传任意文件。 + */ + private void validateAvatarExtension(MultipartFile file) { + String suffix = FileUtil.getSuffix(file.getOriginalFilename()); + if (StrUtil.isBlank(suffix) || !AVATAR_EXTENSIONS.contains(suffix.toLowerCase())) { + throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION, "头像仅支持 png / jpg / jpeg / webp / gif 格式"); + } } @DeleteMapping diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c136098d..3817124a 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -208,6 +208,9 @@ storage: access-key-id: LTAI5t8meTkKjPrPCW9cLtjo access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1 bucket: ttstd + # 自定义访问域名(可选):配置后图标/安装包 URL 走该域名(CDN 或反向代理), + # 留空则使用 https://{bucket}.{endpoint};设备端无法直连 OSS 时配置此项 + domain: # 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制) # 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed # object-acl: public-read diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2f4da0cb..5a787cd2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -203,6 +203,9 @@ storage: access-key-id: LTAI5t8meTkKjPrPCW9cLtjo access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1 bucket: ttstd + # 自定义访问域名(可选):配置后图标/安装包 URL 走该域名(CDN 或反向代理), + # 留空则使用 https://{bucket}.{endpoint};设备端无法直连 OSS 时配置此项 + domain: # 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制) # 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed # object-acl: public-read