From f5a89634c4b8fa6bfc5d1398d5950a354dd0c6ab Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 21 Oct 2026 23:11:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(file):=20=E6=96=87=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=8E=A5=E5=85=A5=20RustFS(S3)=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E4=BB=B6=E5=A4=96=E9=93=BE=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E4=B8=8E=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件上传从本地磁盘改为可切换的对象存储(默认 s3,type 改回 local 即退回原行为): - 新增 common.core.storage 包:ObjectStorage 接口 + S3ObjectStorage(minio 8.5.17) + NoopObjectStorage + FileStorageService + StorageConfig + StoredFile; key 规则沿用 file/yyyyMMdd/uuid.ext、缩略图 file/thumbnail/...,桶内结构对齐旧磁盘目录 - 读取仍走后端代理 GET /api/file/**:s3 模式下首次读取物化到本地 cache-path 再返回, 对外 URL 不变;历史本地文件一律作废,不做回退兼容 - ConfigProperties 增加 storage 配置块,endpoint/access-key/secret-key 支持环境变量覆盖 (RUSTFS_ENDPOINT / RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY),配置非法时启动即失败 - FileRecordService.deleteFileAsync 改为按 path 删除,本地与对象存储同时清理 - pom 固定 okhttp3.version=4.12.0、kotlin.version=1.9.25:Spring Boot 2.5.4 默认的 okhttp 3.14.9 与 minio 所需的 okhttp 4.12.0 冲突,会导致启动即 NoClassDefFoundError: okhttp3/ConnectionPool 同时修正文件外链拼接(以下均为改造前就存在的问题): - application-prod.yml:server-url / file-server 由失效的 tuanwei-api.gxwebsoft.com 改为生产实际域名 https://twai-api.gxmu.edu.cn(与 admin .env.production、 uniapp request.js 一致),此前上传返回的 url 指向旧域名导致访问不到 - FileController 抽出 fileBaseUrl(),分页/列表原来漏了 /api/file 前缀 - OpenFileController 同样补 /api/open/file 前缀 - TzbcyMaterialServiceImpl 下载链接补 /api/file/download --- pom.xml | 12 + .../common/core/config/ConfigProperties.java | 35 ++ .../core/storage/FileStorageService.java | 316 ++++++++++++++++++ .../core/storage/NoopObjectStorage.java | 46 +++ .../common/core/storage/ObjectStorage.java | 50 +++ .../common/core/storage/S3ObjectStorage.java | 135 ++++++++ .../common/core/storage/StorageConfig.java | 36 ++ .../common/core/storage/StorageException.java | 17 + .../common/core/storage/StoredFile.java | 36 ++ .../system/controller/FileController.java | 207 +++++------- .../system/service/FileRecordService.java | 7 +- .../service/impl/FileRecordServiceImpl.java | 13 +- .../impl/TzbcyMaterialServiceImpl.java | 2 +- .../open/controller/OpenFileController.java | 162 ++++----- src/main/resources/application-prod.yml | 22 +- 15 files changed, 867 insertions(+), 229 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/NoopObjectStorage.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/ObjectStorage.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/S3ObjectStorage.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/StorageConfig.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/StorageException.java create mode 100644 src/main/java/com/gxwebsoft/common/core/storage/StoredFile.java diff --git a/pom.xml b/pom.xml index 38b0889..4495a36 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,11 @@ 1.8 UTF-8 UTF-8 + + 4.12.0 + 1.9.25 @@ -174,6 +179,13 @@ 2.1.0 + + + io.minio + minio + 8.5.17 + + org.springframework.boot diff --git a/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java b/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java index 29b2096..a887d0c 100644 --- a/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java +++ b/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java @@ -84,4 +84,39 @@ public class ConfigProperties { // 网关地址 private String serverUrl; + // 文件存储(本地磁盘/对象存储) + private Storage storage = new Storage(); + + /** + * 对象存储配置。type=local 时行为与改造前完全一致,可随时回滚。 + */ + @Data + public static class Storage { + /** + * local | s3 + */ + private String type = "local"; + + /** + * S3 兼容端点,如 http://127.0.0.1:9000(RustFS/MinIO) + */ + private String endpoint; + + private String accessKey; + + private String secretKey; + + /** + * 存储桶,不存在时启动首次写入会自动创建 + */ + private String bucket = "tuanwei"; + + private String region = "us-east-1"; + + /** + * 对象存储文件的本地物化缓存目录,默认 uploadPath + filecache/ + */ + private String cachePath; + } + } diff --git a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java new file mode 100644 index 0000000..92625cd --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java @@ -0,0 +1,316 @@ +package com.gxwebsoft.common.core.storage; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.utils.FileServerUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +/** + * 文件存取服务:上传、读取、删除的唯一入口。 + *

+ * 启用对象存储时文件只存在于对象存储里(本地缓存目录仅为读取时的物化副本,可随时清空); + * 未启用时退回本地磁盘,仅供没有对象存储的开发环境使用。 + * 键与磁盘相对路径对齐:原图 {@code file/20261021/xxx.jpg}, + * 缩略图 {@code file/thumbnail/20261021/xxx.jpg}。 + * + * @author WebSoft + */ +@Service +public class FileStorageService { + + private static final Logger log = LoggerFactory.getLogger(FileStorageService.class); + + /** + * 对象存储中文件前缀,与磁盘上的 file/ 目录对应 + */ + private static final String KEY_PREFIX = "file/"; + + @Resource + private ConfigProperties config; + + @Resource + private ObjectStorage objectStorage; + + /** + * 是否启用了对象存储 + */ + public boolean isRemote() { + return objectStorage.enabled(); + } + + /** + * 本地文件目录:未启用对象存储时作为存储位置,启用后只用于临时文件 + */ + public File getUploadDir() { + return new File(config.getUploadPath(), "file"); + } + + /** + * 对象存储物化缓存目录:对象存储里的文件首次被读取时落在这里,后续直接命中本地。 + * 该目录可随时清空,不承载唯一副本。 + */ + public File getCacheDir() { + String path = config.getStorage() == null ? null : config.getStorage().getCachePath(); + if (StrUtil.isBlank(path)) { + path = config.getUploadPath() + "filecache/"; + } + return new File(path); + } + + // ------------------------------------------------------------------ 写入 + + /** + * 保存上传文件 + */ + public StoredFile store(MultipartFile file) throws IOException { + String originalName = file.getOriginalFilename(); + String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date()); + String suffix = suffixOf(originalName); + boolean uuidName = isUuidName(); + String name = uuidName ? UUID.randomUUID().toString().replaceAll("-", "") + suffix : originalName; + + if (!isRemote()) { + File out = FileServerUtil.upload(file, getUploadDir().getAbsolutePath() + "/", uuidName); + String path = "/" + dateDir + "/" + out.getName(); + return new StoredFile(path, out.length(), FileServerUtil.getContentType(out), + StrUtil.isBlank(originalName) ? out.getName() : originalName); + } + + String path = "/" + dateDir + "/" + name; + if (!uuidName) { + // 对象存储没有文件名冲突,这里手动避让,保持与原逻辑一致 + String prefix = StrUtil.removeSuffix(originalName, suffix); + int sameSize = 2; + while (existsInStore(path)) { + path = "/" + dateDir + "/" + prefix + "(" + sameSize + ")" + suffix; + sameSize++; + } + } + File tmp = new File(new File(getCacheDir(), "tmp/" + dateDir), StrUtil.subAfter(path, "/", true)); + FileUtil.mkParentDirs(tmp); + file.transferTo(tmp); + try { + String contentType = FileServerUtil.getContentType(tmp); + putObject(KEY_PREFIX + normalize(path), tmp, contentType); + return new StoredFile(path, tmp.length(), contentType, + StrUtil.isBlank(originalName) ? tmp.getName() : originalName); + } finally { + FileUtil.del(tmp); + } + } + + /** + * 保存 base64 图片 + */ + public StoredFile storeBase64(String base64, String fileName) throws IOException { + if (StrUtil.isBlank(base64) || !base64.startsWith("data:image/") || !base64.contains(";base64,")) { + throw new IllegalArgumentException("base64 data error"); + } + boolean uuidName = StrUtil.isBlank(fileName); + if (!isRemote()) { + File out = FileServerUtil.upload(base64, fileName, getUploadDir().getAbsolutePath() + "/"); + String path = "/" + out.getParentFile().getName() + "/" + out.getName(); + return new StoredFile(path, out.length(), FileServerUtil.getContentType(out), + uuidName ? out.getName() : fileName); + } + + String suffix = "." + base64.substring(11, base64.indexOf(";")); + String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date()); + String name = uuidName ? UUID.randomUUID().toString().replaceAll("-", "") + suffix : fileName; + String path = "/" + dateDir + "/" + name; + + File tmp = new File(new File(getCacheDir(), "tmp/" + dateDir), name); + FileUtil.mkParentDirs(tmp); + byte[] bytes = Base64.getDecoder().decode(base64.substring(base64.indexOf(";") + 8)); + FileUtil.writeBytes(bytes, tmp); + try { + String contentType = FileServerUtil.getContentType(tmp); + putObject(KEY_PREFIX + normalize(path), tmp, contentType); + return new StoredFile(path, tmp.length(), contentType, uuidName ? name : fileName); + } finally { + FileUtil.del(tmp); + } + } + + // ------------------------------------------------------------------ 读取 + + /** + * 解析原文件:本地缓存命中直接返回,否则从对象存储拉取并缓存。 + * + * @param path 相对路径,形如 {@code /20261021/xxx.jpg} 或 {@code 20261021/xxx.jpg} + * @return 本地可读文件,不存在返回 null + */ + public File resolve(String path) { + String rel = normalize(path); + if (rel == null) { + return null; + } + if (!isRemote()) { + File local = new File(getUploadDir(), rel); + return local.isFile() ? local : null; + } + File cached = new File(getCacheDir(), rel); + if (cached.isFile()) { + return cached; + } + return materialize(KEY_PREFIX + rel, cached); + } + + /** + * 解析已存在的缩略图,不做生成 + */ + public File resolveThumbnail(String path) { + String rel = normalize(path); + if (rel == null) { + return null; + } + if (!isRemote()) { + File local = new File(new File(getUploadDir(), "thumbnail"), rel); + return local.isFile() ? local : null; + } + File cached = new File(getCacheDir(), "thumbnail/" + rel); + if (cached.isFile()) { + return cached; + } + return materialize(KEY_PREFIX + "thumbnail/" + rel, cached); + } + + /** + * 缩略图生成目标位置(本地缓存目录),生成后由 {@link #publishThumbnail} 回传对象存储 + */ + public File thumbnailTarget(String path) { + String rel = normalize(path); + if (rel == null) { + return null; + } + File target = new File(getCacheDir(), "thumbnail/" + rel); + FileUtil.mkParentDirs(target); + return target; + } + + /** + * 缩略图在本地生成成功后回传对象存储;非图片或未生成时静默跳过 + */ + public void publishThumbnail(String path, File thumbnail) { + if (!isRemote() || thumbnail == null || !thumbnail.isFile()) { + return; + } + String rel = normalize(path); + if (rel == null) { + return; + } + try { + putObject(KEY_PREFIX + "thumbnail/" + rel, thumbnail, FileServerUtil.getContentType(thumbnail)); + } catch (Exception e) { + log.warn("[storage] 缩略图回传失败 {}: {}", rel, e.toString()); + } + } + + // ------------------------------------------------------------------ 删除 + + /** + * 删除一个文件的全部副本:原图、缩略图、本地缓存 + */ + public void deleteAll(String path) { + String rel = normalize(path); + if (rel == null) { + return; + } + FileUtil.del(new File(getCacheDir(), rel)); + FileUtil.del(new File(getCacheDir(), "thumbnail/" + rel)); + if (isRemote()) { + objectStorage.delete(KEY_PREFIX + rel); + objectStorage.delete(KEY_PREFIX + "thumbnail/" + rel); + } else { + FileUtil.del(new File(getUploadDir(), rel)); + FileUtil.del(new File(new File(getUploadDir(), "thumbnail"), rel)); + } + } + + // ------------------------------------------------------------------ 内部工具 + + private void putObject(String key, File file, String contentType) { + try (InputStream in = new FileInputStream(file)) { + objectStorage.put(key, in, file.length(), contentType); + } catch (IOException e) { + throw new StorageException("读取待上传文件失败: " + file.getAbsolutePath(), e); + } + } + + /** + * 从对象存储拉取到本地缓存 + */ + private File materialize(String key, File target) { + if (!objectStorage.exists(key)) { + return null; + } + File tmp = new File(target.getParentFile(), target.getName() + ".part"); + try (InputStream in = objectStorage.get(key)) { + if (in == null) { + return null; + } + FileUtil.mkParentDirs(target); + try (OutputStream out = new FileOutputStream(tmp)) { + IoUtil.copy(in, out); + } + if (target.exists() && !target.delete()) { + log.warn("[storage] 无法覆盖缓存文件 {}", target); + } + if (!tmp.renameTo(target)) { + log.warn("[storage] 缓存文件重命名失败 {}", target); + return tmp.isFile() ? tmp : null; + } + return target.isFile() ? target : null; + } catch (Exception e) { + log.warn("[storage] 物化对象失败 {}: {}", key, e.toString()); + FileUtil.del(tmp); + return null; + } + } + + private boolean existsInStore(String path) { + String rel = normalize(path); + return rel != null && objectStorage.exists(KEY_PREFIX + rel); + } + + private String suffixOf(String name) { + return (name == null || !name.contains(".")) ? "" : name.substring(name.lastIndexOf(".")); + } + + private boolean isUuidName() { + return !Boolean.FALSE.equals(config.getUploadUuidName()); + } + + /** + * 归一化为不带前导斜杠的相对路径,并挡掉目录穿越 + */ + private String normalize(String path) { + if (StrUtil.isBlank(path)) { + return null; + } + String rel = path.replace("\\", "/"); + rel = StrUtil.removePrefix(rel, "/"); + if (rel.contains("..")) { + log.warn("[storage] 非法路径已拒绝: {}", path); + return null; + } + return rel; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/NoopObjectStorage.java b/src/main/java/com/gxwebsoft/common/core/storage/NoopObjectStorage.java new file mode 100644 index 0000000..5005424 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/NoopObjectStorage.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.common.core.storage; + +import java.io.InputStream; + +/** + * 未启用对象存储时的占位实现(纯本地磁盘模式) + * + * @author WebSoft + */ +public class NoopObjectStorage implements ObjectStorage { + + public static final NoopObjectStorage INSTANCE = new NoopObjectStorage(); + + private NoopObjectStorage() { + } + + @Override + public boolean enabled() { + return false; + } + + @Override + public void put(String key, InputStream in, long size, String contentType) { + throw new StorageException("对象存储未启用, 无法写入: " + key); + } + + @Override + public InputStream get(String key) { + return null; + } + + @Override + public boolean exists(String key) { + return false; + } + + @Override + public void delete(String key) { + // 本地模式无需处理 + } + + @Override + public String describe() { + return "local"; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/ObjectStorage.java b/src/main/java/com/gxwebsoft/common/core/storage/ObjectStorage.java new file mode 100644 index 0000000..5fb6c13 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/ObjectStorage.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.common.core.storage; + +import java.io.InputStream; + +/** + * 对象存储抽象。 + *

+ * 只有 {@code config.storage.type=s3} 时才会拿到 {@link S3ObjectStorage} 实现, + * 其余情况注入的是 {@link NoopObjectStorage},此时 {@link #enabled()} 返回 false, + * 调用方应回退到本地磁盘(仅用于没有对象存储的开发环境)。 + * + * @author WebSoft + */ +public interface ObjectStorage { + + /** + * 是否真正启用了对象存储 + */ + boolean enabled(); + + /** + * 上传对象 + * + * @param key 对象键,如 {@code file/20261021/xxx.jpg} + * @param in 输入流 + * @param size 字节数,未知传 -1 + * @param contentType 内容类型,可为空 + */ + void put(String key, InputStream in, long size, String contentType); + + /** + * 读取对象,不存在返回 null + */ + InputStream get(String key); + + /** + * 对象是否存在 + */ + boolean exists(String key); + + /** + * 删除对象,不存在视为成功 + */ + void delete(String key); + + /** + * 描述信息,用于启动日志 + */ + String describe(); +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/S3ObjectStorage.java b/src/main/java/com/gxwebsoft/common/core/storage/S3ObjectStorage.java new file mode 100644 index 0000000..a2c13c5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/S3ObjectStorage.java @@ -0,0 +1,135 @@ +package com.gxwebsoft.common.core.storage; + +import cn.hutool.core.util.StrUtil; +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.StatObjectArgs; +import io.minio.errors.ErrorResponseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; + +/** + * 基于 S3 协议的对象存储实现,兼容 MinIO / RustFS。 + * + * @author WebSoft + */ +public class S3ObjectStorage implements ObjectStorage { + + private static final Logger log = LoggerFactory.getLogger(S3ObjectStorage.class); + + private final MinioClient client; + private final String endpoint; + private final String bucket; + private final String region; + + /** + * 桶是否已确认存在,首次写入时懒检查,避免对象存储比后端启动晚时启动失败 + */ + private volatile boolean bucketReady = false; + + public S3ObjectStorage(String endpoint, String accessKey, String secretKey, String bucket, String region) { + if (StrUtil.isBlank(endpoint) || StrUtil.isBlank(accessKey) || StrUtil.isBlank(secretKey) || StrUtil.isBlank(bucket)) { + throw new StorageException("对象存储配置不完整: endpoint/access-key/secret-key/bucket 均不能为空"); + } + MinioClient.Builder builder = MinioClient.builder() + .endpoint(endpoint.trim()) + .credentials(accessKey.trim(), secretKey.trim()); + if (StrUtil.isNotBlank(region)) { + builder.region(region.trim()); + } + this.client = builder.build(); + this.endpoint = endpoint.trim(); + this.bucket = bucket.trim(); + this.region = region; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public void put(String key, InputStream in, long size, String contentType) { + ensureBucket(); + try { + client.putObject(PutObjectArgs.builder() + .bucket(bucket) + .object(key) + .stream(in, size, -1) + .contentType(StrUtil.isBlank(contentType) ? "application/octet-stream" : contentType) + .build()); + } catch (Exception e) { + throw new StorageException("上传对象失败: " + key + ", " + e.getMessage(), e); + } + } + + @Override + public InputStream get(String key) { + try { + return client.getObject(GetObjectArgs.builder().bucket(bucket).object(key).build()); + } catch (ErrorResponseException e) { + // 对象不存在属于正常分支,不打印堆栈 + return null; + } catch (Exception e) { + log.warn("[storage] 读取对象失败 {}: {}", key, e.toString()); + return null; + } + } + + @Override + public boolean exists(String key) { + try { + client.statObject(StatObjectArgs.builder().bucket(bucket).object(key).build()); + return true; + } catch (ErrorResponseException e) { + return false; + } catch (Exception e) { + log.warn("[storage] 探测对象失败 {}: {}", key, e.toString()); + return false; + } + } + + @Override + public void delete(String key) { + try { + client.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(key).build()); + } catch (Exception e) { + log.warn("[storage] 删除对象失败 {}: {}", key, e.toString()); + } + } + + @Override + public String describe() { + return "s3://" + bucket + "@" + endpoint + (StrUtil.isBlank(region) ? "" : " (" + region + ")"); + } + + /** + * 确认桶存在,不存在则创建;失败时下次写入重试 + */ + private void ensureBucket() { + if (bucketReady) { + return; + } + synchronized (this) { + if (bucketReady) { + return; + } + try { + if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) { + client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); + log.info("[storage] 已自动创建桶 {}", bucket); + } + bucketReady = true; + log.info("[storage] 对象存储就绪: {}", describe()); + } catch (Exception e) { + throw new StorageException("对象存储不可用(" + endpoint + "): " + e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/StorageConfig.java b/src/main/java/com/gxwebsoft/common/core/storage/StorageConfig.java new file mode 100644 index 0000000..eacaa8b --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/StorageConfig.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.common.core.storage; + +import com.gxwebsoft.common.core.config.ConfigProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 对象存储装配。{@code config.storage.type=local} 时注入空实现(退回本地磁盘,仅开发环境用)。 + * + * @author WebSoft + */ +@Configuration +public class StorageConfig { + + private static final Logger log = LoggerFactory.getLogger(StorageConfig.class); + + @Bean + public ObjectStorage objectStorage(ConfigProperties configProperties) { + ConfigProperties.Storage storage = configProperties.getStorage(); + if (storage == null || !"s3".equalsIgnoreCase(storage.getType())) { + log.info("[storage] 使用本地磁盘存储: {}", configProperties.getUploadPath()); + return NoopObjectStorage.INSTANCE; + } + try { + S3ObjectStorage s3 = new S3ObjectStorage(storage.getEndpoint(), storage.getAccessKey(), + storage.getSecretKey(), storage.getBucket(), storage.getRegion()); + log.info("[storage] 使用对象存储: {}", s3.describe()); + return s3; + } catch (Exception e) { + // 配置写了 s3 就不能悄悄退回磁盘:否则文件会写进没人管理的本地目录 + throw new IllegalStateException("[storage] 对象存储配置错误, 请检查 config.storage.*: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/StorageException.java b/src/main/java/com/gxwebsoft/common/core/storage/StorageException.java new file mode 100644 index 0000000..a73d812 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/StorageException.java @@ -0,0 +1,17 @@ +package com.gxwebsoft.common.core.storage; + +/** + * 对象存储异常 + * + * @author WebSoft + */ +public class StorageException extends RuntimeException { + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/StoredFile.java b/src/main/java/com/gxwebsoft/common/core/storage/StoredFile.java new file mode 100644 index 0000000..51c1b3a --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/StoredFile.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.common.core.storage; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * 一次上传的结果 + * + * @author WebSoft + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StoredFile { + + /** + * 相对路径,形如 {@code /20261021/xxx.jpg},与 sys_file_record.path 一致 + */ + private String path; + + /** + * 字节数 + */ + private long size; + + /** + * 内容类型(Tika 识别) + */ + private String contentType; + + /** + * 原始文件名 + */ + private String originalName; +} diff --git a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java index 828a822..6995edc 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java @@ -1,16 +1,15 @@ package com.gxwebsoft.common.system.controller; import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.storage.FileStorageService; +import com.gxwebsoft.common.core.storage.StoredFile; import com.gxwebsoft.common.core.utils.FileServerUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.system.entity.FileRecord; -import com.gxwebsoft.common.system.entity.Role; -import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.param.FileRecordParam; import com.gxwebsoft.common.system.service.FileRecordService; import io.swagger.annotations.Api; @@ -26,7 +25,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -44,6 +43,8 @@ public class FileController extends BaseController { private ConfigProperties config; @Resource private FileRecordService fileRecordService; + @Resource + private FileStorageService fileStorageService; @OperationLog @ApiOperation("上传文件") @@ -51,25 +52,8 @@ public class FileController extends BaseController { public ApiResult upload(@RequestParam MultipartFile file, HttpServletRequest request) { FileRecord result = null; try { - String dir = getUploadDir(); - File upload = FileServerUtil.upload(file, dir, config.getUploadUuidName()); - String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/upload"); - String requestURL = config.getFileServer() + "/api/file/"; - String originalName = file.getOriginalFilename(); - result = new FileRecord(); - result.setCreateUserId(getLoginUserId()); - result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName); - result.setLength(upload.length()); - result.setPath(path); - result.setUrl((requestURL + path).replace("file//", "file/")); - String contentType = FileServerUtil.getContentType(upload); - result.setContentType(contentType); - if (FileServerUtil.isImage(contentType)) { - result.setThumbnail(requestURL + "/thumbnail" + path); - } - result.setDownloadUrl(config.getFileServer() + "/download" + path); -// result.setMerchantCode(getMerchantCode()); + StoredFile stored = fileStorageService.store(file); + result = buildRecord(stored); fileRecordService.save(result); return success(result); } catch (Exception e) { @@ -89,18 +73,8 @@ public class FileController extends BaseController { public ApiResult uploadBase64(String base64, String fileName, HttpServletRequest request) { FileRecord result = null; try { - String dir = getUploadDir(); - File upload = FileServerUtil.upload(base64, fileName, getUploadDir()); - String path = upload.getAbsolutePath().substring(dir.length()).replace("\\", "/"); - String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/upload/base64"); - result = new FileRecord(); - result.setCreateUserId(getLoginUserId()); - result.setName(StrUtil.isBlank(fileName) ? upload.getName() : fileName); - result.setLength(upload.length()); - result.setPath(path); - result.setUrl(requestURL + path); - result.setThumbnail(FileServerUtil.isImage(upload) ? (requestURL + "/thumbnail" + path) : null); -// result.setMerchantCode(getMerchantCode()); + StoredFile stored = fileStorageService.storeBase64(base64, fileName); + result = buildRecord(stored); fileRecordService.save(result); return success(result); } catch (Exception e) { @@ -113,42 +87,22 @@ public class FileController extends BaseController { @ApiOperation("上传图片") @PostMapping("/image") public HashMap image(@RequestParam MultipartFile file, HttpServletRequest request) { - FileRecord result = null; - try { - String dir = getUploadDir(); - File upload = FileServerUtil.upload(file, dir, config.getUploadUuidName()); - String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); -// System.out.println("request.getRequestURL() = " + request.getRequestURL()); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/image"); - String requestURL = config.getFileServer() + "/api/file"; -// System.out.println("requestURL = " + requestURL); -// config.getServerUrl() - String originalName = file.getOriginalFilename(); - result = new FileRecord(); - result.setCreateUserId(getLoginUserId()); - result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName); - result.setLength(upload.length()); - result.setPath(path); - result.setUrl(config.getFileServer() + "/api/file" + path); - String contentType = FileServerUtil.getContentType(upload); - result.setContentType(contentType); - if (FileServerUtil.isImage(contentType)) { - result.setThumbnail(requestURL + "/thumbnail" + path); + FileRecord result = null; + try { + StoredFile stored = fileStorageService.store(file); + result = buildRecord(stored); + final HashMap map = new HashMap<>(); + map.put("name", result.getName()); + map.put("status", "done"); + map.put("thumbUrl", result.getThumbnail()); + map.put("downloadUrl", result.getDownloadUrl()); + map.put("url", result.getUrl()); + fileRecordService.save(result); + return map; + } catch (Exception e) { + e.printStackTrace(); + return null; } - result.setDownloadUrl(requestURL + "/download" + path); -// result.setMerchantCode(getMerchantCode()); - final HashMap map = new HashMap<>(); - map.put("name",result.getName()); - map.put("status","done"); - map.put("thumbUrl",result.getThumbnail()); - map.put("downloadUrl",result.getDownloadUrl()); - map.put("url",result.getUrl()); - fileRecordService.save(result); - return map; - } catch (Exception e) { - e.printStackTrace(); - return null; - } } @PreAuthorize("hasAuthority('sys:file:list')") @@ -156,14 +110,15 @@ public class FileController extends BaseController { @ApiOperation("根据id查询文件") @GetMapping("/{id}") public ApiResult get(@PathVariable("id") Integer id) { - return success(fileRecordService.getByIdRel(id)); + return success(fileRecordService.getByIdRel(id)); } @ApiOperation("查看原文件") @GetMapping("/{dir}/{name:.+}") public void preview(@PathVariable("dir") String dir, @PathVariable("name") String name, HttpServletResponse response, HttpServletRequest request) { - File file = new File(getUploadDir(), dir + "/" + name); + // 缓存命中直接返回,否则从对象存储拉到本地缓存 + File file = fileStorageService.resolve(dir + "/" + name); FileServerUtil.preview(file, getPdfOutDir(), config.getOpenOfficeHome(), response, request); } @@ -172,9 +127,9 @@ public class FileController extends BaseController { public void download(@PathVariable("dir") String dir, @PathVariable("name") String name, HttpServletResponse response, HttpServletRequest request) { String path = dir + "/" + name; - FileRecord record = fileRecordService.getByIdPath(path); - File file = new File(getUploadDir(), path); - String fileName = record == null ? file.getName() : record.getName(); + FileRecord record = fileRecordService.getByIdPath("/" + path); + File file = fileStorageService.resolve(path); + String fileName = record == null ? (file == null ? name : file.getName()) : record.getName(); FileServerUtil.preview(file, true, fileName, null, null, response, request); } @@ -182,9 +137,21 @@ public class FileController extends BaseController { @GetMapping("/thumbnail/{dir}/{name:.+}") public void thumbnail(@PathVariable("dir") String dir, @PathVariable("name") String name, HttpServletResponse response, HttpServletRequest request) { - File file = new File(getUploadDir(), dir + "/" + name); - File thumbnail = new File(getUploadSmDir(), dir + "/" + name); - FileServerUtil.previewThumbnail(file, thumbnail, config.getThumbnailSize(), response, request); + String path = dir + "/" + name; + File thumbnail = fileStorageService.resolveThumbnail(path); + if (thumbnail != null) { + FileServerUtil.preview(thumbnail, null, null, response, request); + return; + } + File file = fileStorageService.resolve(path); + if (file == null) { + FileServerUtil.outNotFund(response); + return; + } + File target = fileStorageService.thumbnailTarget(path); + FileServerUtil.previewThumbnail(file, target, config.getThumbnailSize(), response, request); + // 本地生成成功后回传对象存储,下次直接命中 + fileStorageService.publishThumbnail(path, target); } @PreAuthorize("hasAuthority('sys:file:remove')") @@ -194,11 +161,8 @@ public class FileController extends BaseController { public ApiResult remove(@PathVariable("id") Integer id) { FileRecord record = fileRecordService.getById(id); if (fileRecordService.removeById(id)) { - if (StrUtil.isNotBlank(record.getPath())) { - fileRecordService.deleteFileAsync(Arrays.asList( - new File(getUploadDir(), record.getPath()), - new File(getUploadSmDir(), record.getPath()) - )); + if (record != null && StrUtil.isNotBlank(record.getPath())) { + fileRecordService.deleteFileAsync(Collections.singletonList(record.getPath())); } return success("删除成功"); } @@ -215,14 +179,13 @@ public class FileController extends BaseController { public ApiResult deleteBatch(@RequestBody List ids) { List fileRecords = fileRecordService.listByIds(ids); if (fileRecordService.removeByIds(ids)) { - List files = new ArrayList<>(); + List paths = new ArrayList<>(); for (FileRecord record : fileRecords) { if (StrUtil.isNotBlank(record.getPath())) { - files.add(new File(getUploadDir(), record.getPath())); - files.add(new File(getUploadSmDir(), record.getPath())); + paths.add(record.getPath()); } } - fileRecordService.deleteFileAsync(files); + fileRecordService.deleteFileAsync(paths); return success("删除成功"); } return fail("删除失败"); @@ -233,13 +196,8 @@ public class FileController extends BaseController { @ApiOperation("分页查询文件") @GetMapping("/page") public ApiResult> page(FileRecordParam param, HttpServletRequest request) { - // 搜索条件 -// if (getMerchantCode() != null) { -// param.setMerchantCode(getMerchantCode()); -// } PageResult result = fileRecordService.pageRel(param); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/page"); - String requestURL = config.getFileServer(); + String requestURL = fileBaseUrl(); for (FileRecord record : result.getList()) { if (StrUtil.isNotBlank(record.getPath())) { record.setUrl(requestURL + record.getPath()); @@ -258,8 +216,7 @@ public class FileController extends BaseController { @GetMapping("/list") public ApiResult> list(FileRecordParam param, HttpServletRequest request) { List records = fileRecordService.listRel(param); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/list"); - String requestURL = config.getFileServer(); + String requestURL = fileBaseUrl(); for (FileRecord record : records) { if (StrUtil.isNotBlank(record.getPath())) { record.setUrl(requestURL + record.getPath()); @@ -273,38 +230,36 @@ public class FileController extends BaseController { } /** - * 文件上传基目录 + * 组装文件记录,接口地址统一以 config.file-server 为准(历史实现里 downloadUrl 漏了 /api/file 前缀) */ - private String getUploadBaseDir() { - return config.getUploadPath() + "file/"; + /** + * 文件对外访问基址,形如 https://twai-api.gxmu.edu.cn/api/file + */ + private String fileBaseUrl() { + return StrUtil.removeSuffix(config.getFileServer(), "/") + "/api/file"; } - /** - * 文件上传位置(服务器) - */ - private String getUploadDir() { - return config.getUploadPath() + "file/"; - } - - /** - * 文件上传位置(本地) - */ -// private String getUploadDir() { -// return "/Users/gxwebsoft/Documents/uploads/"; -// } - - /** - * 缩略图生成位置 - */ - private String getUploadSmDir() { - return getUploadBaseDir() + "thumbnail/"; + private FileRecord buildRecord(StoredFile stored) { + FileRecord record = new FileRecord(); + record.setCreateUserId(getLoginUserId()); + record.setName(stored.getOriginalName()); + record.setLength(stored.getSize()); + record.setPath(stored.getPath()); + String base = fileBaseUrl(); + record.setUrl(base + stored.getPath()); + record.setContentType(stored.getContentType()); + if (FileServerUtil.isImage(stored.getContentType())) { + record.setThumbnail(base + "/thumbnail" + stored.getPath()); + } + record.setDownloadUrl(base + "/download" + stored.getPath()); + return record; } /** * office转pdf输出位置 */ private String getPdfOutDir() { - return getUploadBaseDir() + "pdf/"; + return new File(fileStorageService.getUploadDir(), "pdf").getAbsolutePath() + "/"; } @PreAuthorize("hasAuthority('sys:file:upload')") @@ -312,10 +267,10 @@ public class FileController extends BaseController { @ApiOperation("添加文件") @PostMapping() public ApiResult save(@RequestBody FileRecord fileRecord) { - if (fileRecordService.save(fileRecord)) { - return success("上传成功"); - } - return fail("上传失败"); + if (fileRecordService.save(fileRecord)) { + return success("上传成功"); + } + return fail("上传失败"); } @PreAuthorize("hasAuthority('sys:file:update')") @@ -323,10 +278,10 @@ public class FileController extends BaseController { @ApiOperation("修改文件") @PutMapping() public ApiResult update(@RequestBody FileRecord fileRecord) { - if (fileRecordService.updateById(fileRecord)) { - return success("修改成功"); - } - return fail("修改失败"); + if (fileRecordService.updateById(fileRecord)) { + return success("修改成功"); + } + return fail("修改失败"); } } diff --git a/src/main/java/com/gxwebsoft/common/system/service/FileRecordService.java b/src/main/java/com/gxwebsoft/common/system/service/FileRecordService.java index 3dd09ac..e4b7ca3 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/FileRecordService.java +++ b/src/main/java/com/gxwebsoft/common/system/service/FileRecordService.java @@ -5,7 +5,6 @@ import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.system.entity.FileRecord; import com.gxwebsoft.common.system.param.FileRecordParam; -import java.io.File; import java.util.List; /** @@ -49,10 +48,10 @@ public interface FileRecordService extends IService { FileRecord getByIdPath(String path); /** - * 异步删除文件 + * 异步删除文件(对象存储 + 本地缓存,按相对路径) * - * @param files 文件数组 + * @param paths 文件相对路径数组,形如 /20261021/xxx.jpg */ - void deleteFileAsync(List files); + void deleteFileAsync(List paths); } diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java index 72c9c27..a46dbe6 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/FileRecordServiceImpl.java @@ -1,6 +1,7 @@ package com.gxwebsoft.common.system.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.storage.FileStorageService; import com.gxwebsoft.common.core.utils.CommonUtil; import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; @@ -11,7 +12,7 @@ import com.gxwebsoft.common.system.service.FileRecordService; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; +import javax.annotation.Resource; import java.util.List; /** @@ -23,6 +24,9 @@ import java.util.List; @Service public class FileRecordServiceImpl extends ServiceImpl implements FileRecordService { + @Resource + private FileStorageService fileStorageService; + @Override public PageResult pageRel(FileRecordParam param) { PageParam page = new PageParam<>(param); @@ -51,10 +55,11 @@ public class FileRecordServiceImpl extends ServiceImpl files) { - for (File file : files) { + public void deleteFileAsync(List paths) { + for (String path : paths) { try { - file.delete(); + // 对象存储原图/缩略图 + 本地缓存,一次清干净 + fileStorageService.deleteAll(path); } catch (Exception e) { e.printStackTrace(); } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyMaterialServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyMaterialServiceImpl.java index e647518..7467a14 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyMaterialServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyMaterialServiceImpl.java @@ -238,7 +238,7 @@ public class TzbcyMaterialServiceImpl extends ServiceImpl get(@PathVariable("id") Integer id) { - // 验证签名 - isCheckSign(); - return success(fileRecordService.getByIdRel(id)); + // 验证签名 + isCheckSign(); + return success(fileRecordService.getByIdRel(id)); } @ApiOperation("查看原文件") @@ -121,7 +102,7 @@ public class OpenFileController extends BaseController { HttpServletResponse response, HttpServletRequest request) { // 验证签名 isCheckSign(); - File file = new File(getUploadDir(), dir + "/" + name); + File file = fileStorageService.resolve(dir + "/" + name); FileServerUtil.preview(file, getPdfOutDir(), config.getOpenOfficeHome(), response, request); } @@ -132,9 +113,9 @@ public class OpenFileController extends BaseController { // 验证签名 isCheckSign(); String path = dir + "/" + name; - FileRecord record = fileRecordService.getByIdPath(path); - File file = new File(getUploadDir(), path); - String fileName = record == null ? file.getName() : record.getName(); + FileRecord record = fileRecordService.getByIdPath("/" + path); + File file = fileStorageService.resolve(path); + String fileName = record == null ? (file == null ? name : file.getName()) : record.getName(); FileServerUtil.preview(file, true, fileName, null, null, response, request); } @@ -144,9 +125,20 @@ public class OpenFileController extends BaseController { HttpServletResponse response, HttpServletRequest request) { // 验证签名 isCheckSign(); - File file = new File(getUploadDir(), dir + "/" + name); - File thumbnail = new File(getUploadSmDir(), dir + "/" + name); - FileServerUtil.previewThumbnail(file, thumbnail, config.getThumbnailSize(), response, request); + String path = dir + "/" + name; + File thumbnail = fileStorageService.resolveThumbnail(path); + if (thumbnail != null) { + FileServerUtil.preview(thumbnail, null, null, response, request); + return; + } + File file = fileStorageService.resolve(path); + if (file == null) { + FileServerUtil.outNotFund(response); + return; + } + File target = fileStorageService.thumbnailTarget(path); + FileServerUtil.previewThumbnail(file, target, config.getThumbnailSize(), response, request); + fileStorageService.publishThumbnail(path, target); } @OperationLog @@ -157,11 +149,8 @@ public class OpenFileController extends BaseController { isCheckSign(); FileRecord record = fileRecordService.getById(id); if (fileRecordService.removeById(id)) { - if (StrUtil.isNotBlank(record.getPath())) { - fileRecordService.deleteFileAsync(Arrays.asList( - new File(getUploadDir(), record.getPath()), - new File(getUploadSmDir(), record.getPath()) - )); + if (record != null && StrUtil.isNotBlank(record.getPath())) { + fileRecordService.deleteFileAsync(Collections.singletonList(record.getPath())); } return success("删除成功"); } @@ -179,14 +168,13 @@ public class OpenFileController extends BaseController { isCheckSign(); List fileRecords = fileRecordService.listByIds(ids); if (fileRecordService.removeByIds(ids)) { - List files = new ArrayList<>(); + List paths = new ArrayList<>(); for (FileRecord record : fileRecords) { if (StrUtil.isNotBlank(record.getPath())) { - files.add(new File(getUploadDir(), record.getPath())); - files.add(new File(getUploadSmDir(), record.getPath())); + paths.add(record.getPath()); } } - fileRecordService.deleteFileAsync(files); + fileRecordService.deleteFileAsync(paths); return success("删除成功"); } return fail("删除失败"); @@ -198,13 +186,8 @@ public class OpenFileController extends BaseController { public ApiResult> page(FileRecordParam param, HttpServletRequest request) { // 验证签名 isCheckSign(); - // 搜索条件 -// if (getMerchantCode() != null) { -// param.setMerchantCode(getMerchantCode()); -// } PageResult result = fileRecordService.pageRel(param); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/page"); - String requestURL = config.getFileServer(); + String requestURL = openFileBaseUrl(); for (FileRecord record : result.getList()) { if (StrUtil.isNotBlank(record.getPath())) { record.setUrl(requestURL + record.getPath()); @@ -224,8 +207,7 @@ public class OpenFileController extends BaseController { // 验证签名 isCheckSign(); List records = fileRecordService.listRel(param); -// String requestURL = StrUtil.removeSuffix(request.getRequestURL(), "/list"); - String requestURL = config.getFileServer(); + String requestURL = openFileBaseUrl(); for (FileRecord record : records) { if (StrUtil.isNotBlank(record.getPath())) { record.setUrl(requestURL + record.getPath()); @@ -239,63 +221,59 @@ public class OpenFileController extends BaseController { } /** - * 文件上传基目录 + * 开放接口文件外链基址,形如 https://twai-api.gxmu.edu.cn/api/open/file */ - private String getUploadBaseDir() { - return File.listRoots()[config.getUploadLocation()].getAbsolutePath() - .replace("\\", "/") + "/upload/"; + private String openFileBaseUrl() { + return StrUtil.removeSuffix(config.getFileServer(), "/") + "/api/open/file"; } /** - * 文件上传位置(服务器) + * 组装文件记录 */ - private String getUploadDir() { - return config.getUploadPath() + "file/"; - } - - /** - * 文件上传位置(本地) - */ -// private String getUploadDir() { -// return "/Users/gxwebsoft/Documents/uploads/"; -// } - - /** - * 缩略图生成位置 - */ - private String getUploadSmDir() { - return getUploadBaseDir() + "thumbnail/"; + private FileRecord buildRecord(StoredFile stored, String base) { + FileRecord record = new FileRecord(); + record.setCreateUserId(getLoginUserId()); + record.setName(stored.getOriginalName()); + record.setLength(stored.getSize()); + record.setPath(stored.getPath()); + record.setUrl(base + stored.getPath()); + record.setContentType(stored.getContentType()); + if (FileServerUtil.isImage(stored.getContentType())) { + record.setThumbnail(base + "/thumbnail" + stored.getPath()); + } + record.setDownloadUrl(base + "/download" + stored.getPath()); + return record; } /** * office转pdf输出位置 */ private String getPdfOutDir() { - return getUploadBaseDir() + "pdf/"; + return new File(fileStorageService.getUploadDir(), "pdf").getAbsolutePath() + "/"; } @OperationLog @ApiOperation("添加文件") @PostMapping() public ApiResult save(@RequestBody FileRecord fileRecord) { - // 验证签名 - isCheckSign(); - if (fileRecordService.save(fileRecord)) { - return success("上传成功"); - } - return fail("上传失败"); + // 验证签名 + isCheckSign(); + if (fileRecordService.save(fileRecord)) { + return success("上传成功"); + } + return fail("上传失败"); } @OperationLog @ApiOperation("修改文件") @PutMapping() public ApiResult update(@RequestBody FileRecord fileRecord) { - // 验证签名 - isCheckSign(); - if (fileRecordService.updateById(fileRecord)) { - return success("修改成功"); - } - return fail("修改失败"); + // 验证签名 + isCheckSign(); + if (fileRecordService.updateById(fileRecord)) { + return success("修改成功"); + } + return fail("修改失败"); } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index df9cb6a..5d99cc8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -33,9 +33,27 @@ logging: # 框架配置 config: # 开发环境接口 - server-url: https://tuanwei-api.gxwebsoft.com/api + # 对外访问域名,前端(vite VITE_API_URL / uniapp API_BASE_URL)用的就是这个 + server-url: https://twai-api.gxmu.edu.cn/api upload-path: /www/wwwroot/tuanwei/ - file-server: https://tuanwei-api.gxwebsoft.com + # 文件外链前缀:数据库里回给前端的 url = 这里 + /api/file + 相对路径 + file-server: https://twai-api.gxmu.edu.cn + # 文件存储:local(本地磁盘,改造前行为)| s3(对象存储) + # 生产指向 1Panel 里的 RustFS,端口只发布到宿主机 127.0.0.1,不对外开。 + # 改造前的历史图片一律作废、不做兼容读取;type 改回 local 即退回本地磁盘。 + # 后端本身是容器(twai-api),容器内的 127.0.0.1 是它自己,连不到 RustFS, + # 必须走 1panel-network 的容器地址。默认写容器名,Docker 内建 DNS 按名解析, + # RustFS 重建换 IP 也不受影响;万一容器名解析不通,用环境变量换成网络内 IP: + # RUSTFS_ENDPOINT=http://172.18.0.6:9000 + storage: + type: s3 + endpoint: ${RUSTFS_ENDPOINT:http://1Panel-rustfs-YrcF:9000} + access-key: ${RUSTFS_ACCESS_KEY:rustfsadmin} + secret-key: ${RUSTFS_SECRET_KEY:rustfsadmin_Qy6wjs} + bucket: tuanwei + region: us-east-1 + # 对象存储文件首次被读取时物化到这里,可随时清空 + cache-path: /www/wwwroot/tuanwei/filecache/ wxpay: private-key-path: /www/wwwroot/tuanwei/cert/apiclient_key.pem