feat(file): 文件存储接入 RustFS(S3),修正文件外链域名与路径
文件上传从本地磁盘改为可切换的对象存储(默认 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
This commit is contained in:
@@ -21,6 +21,11 @@
|
||||
<java.version>1.8</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<!-- Spring Boot 2.5.4 默认把 okhttp 钉在 3.14.9,而 minio 8.5.x 需要 4.x,
|
||||
否则启动时 io.minio.S3Base 会报 RequestBody.create 方法不存在。
|
||||
okhttp 4.x 依赖 kotlin-stdlib,故一并覆盖 Spring Boot 管理的 kotlin 版本。 -->
|
||||
<okhttp3.version>4.12.0</okhttp3.version>
|
||||
<kotlin.version>1.9.25</kotlin.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -174,6 +179,13 @@
|
||||
<version>2.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- minio sdk, 兼容 S3 协议对象存储(RustFS/MinIO) -->
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.17</version>
|
||||
</dependency>
|
||||
|
||||
<!-- spring-boot-mail -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 文件存取服务:上传、读取、删除的唯一入口。
|
||||
* <p>
|
||||
* 启用对象存储时文件只存在于对象存储里(本地缓存目录仅为读取时的物化副本,可随时清空);
|
||||
* 未启用时退回本地磁盘,仅供没有对象存储的开发环境使用。
|
||||
* 键与磁盘相对路径对齐:原图 {@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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.gxwebsoft.common.core.storage;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 对象存储抽象。
|
||||
* <p>
|
||||
* 只有 {@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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<FileRecord> 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<FileRecord> 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) {
|
||||
@@ -115,28 +89,8 @@ public class FileController extends BaseController {
|
||||
public HashMap<String, Object> 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);
|
||||
}
|
||||
result.setDownloadUrl(requestURL + "/download" + path);
|
||||
// result.setMerchantCode(getMerchantCode());
|
||||
StoredFile stored = fileStorageService.store(file);
|
||||
result = buildRecord(stored);
|
||||
final HashMap<String, Object> map = new HashMap<>();
|
||||
map.put("name", result.getName());
|
||||
map.put("status", "done");
|
||||
@@ -163,7 +117,8 @@ public class FileController extends BaseController {
|
||||
@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<Integer> ids) {
|
||||
List<FileRecord> fileRecords = fileRecordService.listByIds(ids);
|
||||
if (fileRecordService.removeByIds(ids)) {
|
||||
List<File> files = new ArrayList<>();
|
||||
List<String> 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<PageResult<FileRecord>> page(FileRecordParam param, HttpServletRequest request) {
|
||||
// 搜索条件
|
||||
// if (getMerchantCode() != null) {
|
||||
// param.setMerchantCode(getMerchantCode());
|
||||
// }
|
||||
PageResult<FileRecord> 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<FileRecord>> list(FileRecordParam param, HttpServletRequest request) {
|
||||
List<FileRecord> 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 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传位置(本地)
|
||||
*/
|
||||
// private String getUploadDir() {
|
||||
// return "/Users/gxwebsoft/Documents/uploads/";
|
||||
// }
|
||||
|
||||
/**
|
||||
* 缩略图生成位置
|
||||
*/
|
||||
private String getUploadSmDir() {
|
||||
return getUploadBaseDir() + "thumbnail/";
|
||||
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')")
|
||||
|
||||
@@ -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> {
|
||||
FileRecord getByIdPath(String path);
|
||||
|
||||
/**
|
||||
* 异步删除文件
|
||||
* 异步删除文件(对象存储 + 本地缓存,按相对路径)
|
||||
*
|
||||
* @param files 文件数组
|
||||
* @param paths 文件相对路径数组,形如 /20261021/xxx.jpg
|
||||
*/
|
||||
void deleteFileAsync(List<File> files);
|
||||
void deleteFileAsync(List<String> paths);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<FileRecordMapper, FileRecord> implements FileRecordService {
|
||||
|
||||
@Resource
|
||||
private FileStorageService fileStorageService;
|
||||
|
||||
@Override
|
||||
public PageResult<FileRecord> pageRel(FileRecordParam param) {
|
||||
PageParam<FileRecord, FileRecordParam> page = new PageParam<>(param);
|
||||
@@ -51,10 +55,11 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
|
||||
|
||||
@Async
|
||||
@Override
|
||||
public void deleteFileAsync(List<File> files) {
|
||||
for (File file : files) {
|
||||
public void deleteFileAsync(List<String> paths) {
|
||||
for (String path : paths) {
|
||||
try {
|
||||
file.delete();
|
||||
// 对象存储原图/缩略图 + 本地缓存,一次清干净
|
||||
fileStorageService.deleteAll(path);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ public class TzbcyMaterialServiceImpl extends ServiceImpl<TzbcyMaterialMapper, T
|
||||
if (!hasText(path)) {
|
||||
return null;
|
||||
}
|
||||
return trimEnd(config.getFileServer()) + "/download" + ensureLeadingSlash(path);
|
||||
return trimEnd(config.getFileServer()) + "/api/file/download" + ensureLeadingSlash(path);
|
||||
}
|
||||
|
||||
private File resolveSourceFile(TzbcyMaterial material, String uploadPath) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.gxwebsoft.open.controller;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
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;
|
||||
@@ -22,7 +24,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.List;
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ public class OpenFileController extends BaseController {
|
||||
private ConfigProperties config;
|
||||
@Resource
|
||||
private FileRecordService fileRecordService;
|
||||
@Resource
|
||||
private FileStorageService fileStorageService;
|
||||
|
||||
@OperationLog
|
||||
@ApiOperation("上传文件")
|
||||
@@ -48,24 +52,10 @@ public class OpenFileController extends BaseController {
|
||||
isCheckSign();
|
||||
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 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(requestURL + "/download/" + path);
|
||||
// result.setMerchantCode(getMerchantCode());
|
||||
StoredFile stored = fileStorageService.store(file);
|
||||
// 开放接口沿用请求域名,避免历史调用方依赖的 host 发生变化
|
||||
String base = StrUtil.removeSuffix(request.getRequestURL().toString(), "/upload");
|
||||
result = buildRecord(stored, base);
|
||||
fileRecordService.save(result);
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
@@ -86,18 +76,9 @@ public class OpenFileController extends BaseController {
|
||||
isCheckSign();
|
||||
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);
|
||||
String base = StrUtil.removeSuffix(request.getRequestURL().toString(), "/upload/base64");
|
||||
result = buildRecord(stored, base);
|
||||
fileRecordService.save(result);
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
@@ -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<FileRecord> fileRecords = fileRecordService.listByIds(ids);
|
||||
if (fileRecordService.removeByIds(ids)) {
|
||||
List<File> files = new ArrayList<>();
|
||||
List<String> 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<PageResult<FileRecord>> page(FileRecordParam param, HttpServletRequest request) {
|
||||
// 验证签名
|
||||
isCheckSign();
|
||||
// 搜索条件
|
||||
// if (getMerchantCode() != null) {
|
||||
// param.setMerchantCode(getMerchantCode());
|
||||
// }
|
||||
PageResult<FileRecord> 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<FileRecord> 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,39 +221,35 @@ 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 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传位置(本地)
|
||||
*/
|
||||
// private String getUploadDir() {
|
||||
// return "/Users/gxwebsoft/Documents/uploads/";
|
||||
// }
|
||||
|
||||
/**
|
||||
* 缩略图生成位置
|
||||
*/
|
||||
private String getUploadSmDir() {
|
||||
return getUploadBaseDir() + "thumbnail/";
|
||||
record.setDownloadUrl(base + "/download" + stored.getPath());
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* office转pdf输出位置
|
||||
*/
|
||||
private String getPdfOutDir() {
|
||||
return getUploadBaseDir() + "pdf/";
|
||||
return new File(fileStorageService.getUploadDir(), "pdf").getAbsolutePath() + "/";
|
||||
}
|
||||
|
||||
@OperationLog
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user