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