feat(storage): 分类图标、安装包、壁纸等文件统一接入云存储服务

This commit is contained in:
2026-10-20 09:53:06 +08:00
parent cbf4f37b94
commit de088ddff8
26 changed files with 568 additions and 220 deletions

View File

@@ -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` 之类的参数决定目录。
- 存量历史数据(旧本地路径、旧对象目录、旧字段格式)只允许用一次性迁移脚本或管理接口处理,不得在业务代码中长期保留运行时回退分支。

View File

@@ -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 = "分类列表(全部,按排序返回)")

View File

@@ -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} 实现
* <p>
* 用于把已落盘的安装包(几百 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);
}
}

View File

@@ -79,7 +79,7 @@ public class AppPackageController {
return Result.success(appPackageService.detail(id));
}
@Operation(summary = "删除安装包(同时删除本地文件)")
@Operation(summary = "删除安装包(同时删除存储文件)")
@DeleteMapping("/{id}")
public Result<Boolean> 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(), "安装包文件不存在");

View File

@@ -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<String> categories);
/**
* 批量查询应用包名对应的最新图标地址(取上传时间最新的非空 icon_url)
* <p>
* 用于设备已安装应用列表的图标兜底:设备端未上报图标(app_icon 无记录)时,
* 回退到安装包解析出来的图标,避免后台/设备端整片图标为空。
*
* @param packageNames 应用包名集合
* @return packageName -> iconUrl
*/
Map<String, String> findLatestIconUrls(Collection<String> packageNames);
}

View File

@@ -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<AppPackageDocument> 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<String, String> findLatestIconUrls(Collection<String> 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<AppPackageDocument> documents = mongoTemplate.find(query, AppPackageDocument.class);
Map<String, String> urlMap = new HashMap<>();
for (AppPackageDocument doc : documents) {
if (doc.getPackageName() != null && doc.getIconUrl() != null) {
// 结果按上传时间倒序,首次出现的即该包名的最新图标
urlMap.putIfAbsent(doc.getPackageName(), doc.getIconUrl());
}
}
return urlMap;
}
}

View File

@@ -51,7 +51,19 @@ public interface AppPackageService {
void updateCategories(String id, List<String> categories);
/**
* 下载安装包文件流。文件不存在时返回 null,由 Controller 返回 404。
* 获取安装包文件访问地址。
* <p>
* 云存储(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

View File

@@ -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<String> 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<String> 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<String> saveScreenshots(MultipartFile[] screenshots) {
List<String> urls = new java.util.ArrayList<>();
List<String> urls = new ArrayList<>();
if (screenshots == null || screenshots.length == 0) {
return urls;
}
File dir = new File(FilePath.getScreenshotPath());
if (!dir.exists()) {
dir.mkdirs();
}
List<String> allowedImgExt = List.of("png", "jpg", "jpeg", "webp");
for (MultipartFile sf : screenshots) {
if (sf == null || sf.isEmpty()) {
@@ -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);

View File

@@ -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。
* <p>文件名规则:{sn}_{纳秒时间戳}_{md5}.{ext},可经 /static/wallpaper/{fileName} 访问。
* 使用 {@code System.nanoTime()} 而非毫秒时间戳,避免同一毫秒上传相同图片时
* 文件名(进而 URL)重复,造成壁纸明细表出现同 URL 多行、排序 sort_no 重复。
* 将壁纸图片上传到配置的存储(storage.type:s3 / aliyun / local),固定落在壁纸目录。
* <p>对象名由存储服务生成(UUID),URL 天然唯一,避免壁纸明细表出现同 URL 多行、
* 排序 sort_no 重复的问题。
* <p>同时计算并返回 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);

View File

@@ -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<ClientFileInfo> 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

View File

@@ -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);
/**
* 删除文件
*

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
package com.youlai.boot.common.config;
/**
* 对象存储业务目录(固定目录,不按日期滚动)
* <p>
* 各类资源落在独立目录,便于按目录配置读权限、生命周期与 CDN 缓存规则;
* 同一类资源的 URL 前缀固定,也便于运维批量迁移与清理。
* <p>
* 约定:目录随业务类型固定,不提供运行时可变目录参数,调用方直接使用这里的常量。
*
* @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";
}

View File

@@ -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 + 图片不更新)。

View File

@@ -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;
/**
* 文件大小(字节)
*/

View File

@@ -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<ApkInstallInfoReq> apkInfos) {
@@ -85,11 +88,14 @@ public class ApkInstallServiceImpl implements ApkInstallService {
.filter(p -> p != null && !p.isBlank())
.toList();
Map<String, String> activeIconUrls = appIconService.getActiveIconUrls(packageNames);
// 兜底:图标库无记录(设备端未上报)时,回退到安装包解析出的图标,避免列表整片无图标
Map<String, String> 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()));

View File

@@ -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<AppIconMapper, AppIcon> 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("保存应用图标失败");
}

View File

@@ -92,10 +92,13 @@ public class DeviceWallpaperServiceImpl extends ServiceImpl<DeviceWallpaperItemM
List<SnDeviceWallpaperItem> 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);

View File

@@ -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);
/**
* 删除文件
*

View File

@@ -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 明文传输。

View File

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

View File

@@ -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))

View File

@@ -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<String> 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<FileInfo> 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

View File

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

View File

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