From 9f52bedd3a4da0e66e63f016712ecc38cdafbebc Mon Sep 17 00:00:00 2001 From: yanzy <1319542051@qq.com> Date: Wed, 19 Nov 2025 22:26:39 +0800 Subject: [PATCH 1/6] =?UTF-8?q?add=20=E5=A2=9E=E5=8A=A0docker=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 29 ++++++--- docker-compose.yml | 61 +++++++++++++++++++ .../src/main/resources/application-fs.yml | 2 +- fs-admin/src/main/resources/application.yml | 2 +- fs-framework/fs-security/pom.xml | 8 +-- 5 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile index a8830412..d83b2770 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,23 @@ -FROM openjdk:8-jdk-alpine -LABEL version="1.2.0" description="free-fs" by="dinghao" -#添加字体库,解决验证码报错 -RUN apk add --no-cache ttf-dejavu -WORKDIR /home/free-fs -ADD target/free-fs.jar /home/free-fs -CMD ["java","-jar","free-fs.jar"] +# 基础镜像 +FROM bellsoft/liberica-openjdk-rocky:17.0.16-cds +# 维护者 +LABEL maintainer="free-fs" + +# 挂载点 +VOLUME /tmp + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 【核心修改点】 +# 因为是多模块,Docker 构建上下文在根目录,所以要指定子模块路径 +# 将 fs-admin 下的 jar 包复制为 app.jar +COPY fs-admin/target/fs-admin.jar app.jar + +# 暴露端口 +EXPOSE 8080 + +# 启动命令 +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..0f701e85 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + # 1. MySQL 8 + mysql: + image: mysql:8.0 + container_name: free-fs-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: free-fs + TZ: Asia/Shanghai + command: + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci + ports: + - "3306:3306" + volumes: + - ./data/mysql:/var/lib/mysql + # 【自动导入SQL】将你项目根目录下的 _sql 文件夹挂载进去 + # MySQL 容器首次启动时会按文件名顺序执行里面的 .sql 文件 + - ./_sql:/docker-entrypoint-initdb.d + + # 2. Redis + redis: + image: redis:6.2 + container_name: free-fs-redis + restart: always + command: redis-server --requirepass 123456 + environment: + TZ: Asia/Shanghai + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + + # 3. 应用服务 + free-fs-app: + build: + context: . + dockerfile: Dockerfile + container_name: free-fs-app + restart: always + ports: + - "8080:8080" + depends_on: + - mysql + - redis + volumes: + # 将当前目录下的 data/upload 映射到 容器内的 /data/upload + - F:\Workspace\IdeaProject\free-fs\temp\upload:/data/upload + environment: + TZ: Asia/Shanghai + # 覆盖配置 (根据你的 application.yml 实际情况微调) + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/free-fs?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + SPRING_REDIS_HOST: redis + SPRING_REDIS_PASSWORD: 123456 + STORAGE_LOCAL_BASE_PATH: /data/upload + STORAGE_LOCAL_BASE_URL: http://localhost:8080/files + FS_SHARE_DOMAIN: http://localhost:5173 \ No newline at end of file diff --git a/fs-admin/src/main/resources/application-fs.yml b/fs-admin/src/main/resources/application-fs.yml index 86aec5fc..46cf7e03 100644 --- a/fs-admin/src/main/resources/application-fs.yml +++ b/fs-admin/src/main/resources/application-fs.yml @@ -2,7 +2,7 @@ fs: # 分享配置 share: - domain: http://localhost:5173 + domain: ${FS_SHARE_DOMAIN:http://localhost:5173} # 预览配置 preview: # 预览文件最大大小(字节),默认500MB diff --git a/fs-admin/src/main/resources/application.yml b/fs-admin/src/main/resources/application.yml index 1f3d5a0a..494205e3 100644 --- a/fs-admin/src/main/resources/application.yml +++ b/fs-admin/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8081 + port: 8080 undertow: threads: io: 8 diff --git a/fs-framework/fs-security/pom.xml b/fs-framework/fs-security/pom.xml index 7aed454b..49ab1eef 100644 --- a/fs-framework/fs-security/pom.xml +++ b/fs-framework/fs-security/pom.xml @@ -26,9 +26,9 @@ cn.dev33 sa-token-jwt - - - - + + cn.dev33 + sa-token-redis-template + -- Gitee From c7912a853116b5b73334b4cc50288253469d8c22 Mon Sep 17 00:00:00 2001 From: Yann <1319542051@qq.com> Date: Thu, 20 Nov 2025 11:37:58 +0800 Subject: [PATCH 2/6] =?UTF-8?q?update=20=E5=A2=9E=E5=8A=A0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E9=85=8D=E7=BD=AE=E7=B1=BB=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=B8=BA=E9=85=8D=E7=BD=AE=E5=8F=91=E9=80=81=E8=80=85=E6=97=B6?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E5=90=AF=E5=8A=A8=E8=AD=A6=E5=91=8A=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fs-admin/src/main/resources/application.yml | 2 + .../fs/framework/notify/mail/MailConfig.java | 26 +++++++++++++ .../mail/service/NoOpJavaMailSender.java | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/MailConfig.java create mode 100644 fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/service/NoOpJavaMailSender.java diff --git a/fs-admin/src/main/resources/application.yml b/fs-admin/src/main/resources/application.yml index 494205e3..af160880 100644 --- a/fs-admin/src/main/resources/application.yml +++ b/fs-admin/src/main/resources/application.yml @@ -40,6 +40,8 @@ spring: # 邮件配置 mail: + # 开关 + enable: false #smtp服务主机 qq邮箱则为smtp.qq.com host: smtp.qq.com #编码集 diff --git a/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/MailConfig.java b/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/MailConfig.java new file mode 100644 index 00000000..33dc7766 --- /dev/null +++ b/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/MailConfig.java @@ -0,0 +1,26 @@ +package com.xddcodec.fs.framework.notify.mail; + + +import com.xddcodec.fs.framework.notify.mail.service.NoOpJavaMailSender; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; + +/** + * + * @author Yann + * @date 2025/11/20 11:30 + */ +@Slf4j +@Configuration +public class MailConfig { + + @Bean + @ConditionalOnProperty(prefix = "spring.mail", name = "enable", havingValue = "false") + public JavaMailSender dummyMailSender() { + log.warn("检测到 spring.mail.enable=false,已启用[空邮件发送器],邮件发送功能将被拦截。"); + return new NoOpJavaMailSender(); + } +} diff --git a/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/service/NoOpJavaMailSender.java b/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/service/NoOpJavaMailSender.java new file mode 100644 index 00000000..2461ac56 --- /dev/null +++ b/fs-framework/fs-notify/src/main/java/com/xddcodec/fs/framework/notify/mail/service/NoOpJavaMailSender.java @@ -0,0 +1,37 @@ +package com.xddcodec.fs.framework.notify.mail.service; + + +import jakarta.mail.internet.MimeMessage; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import java.io.InputStream; + +/** + * + * @author Yann + * @date 2025/11/20 11:31 + */ +public class NoOpJavaMailSender implements JavaMailSender { + + @Override + public MimeMessage createMimeMessage() { + return null; + } + + @Override + public MimeMessage createMimeMessage(InputStream contentStream) throws MailException { + return null; + } + + @Override + public void send(MimeMessage... mimeMessages) throws MailException { + + } + + @Override + public void send(SimpleMailMessage... simpleMessages) throws MailException { + + } +} -- Gitee From 2972c029c30c0e8e00ebf0fc5d7f2d88fa96a17d Mon Sep 17 00:00:00 2001 From: Freedom <459102951@qq.com> Date: Thu, 20 Nov 2025 14:14:37 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-dev.yml | 10 +- fs-admin/src/main/resources/application.yml | 11 - .../factory/PreviewStrategyManager.java | 50 ++- .../impl/UnsupportedPreviewStrategy.java | 2 +- .../context/StoragePlatformContextHolder.java | 151 +------- .../local/config/LocalStorageProperties.java | 4 +- .../file/controller/FileStreamController.java | 352 ++++++------------ .../fs/file/preview/PreviewService.java | 5 - .../service/impl/FileShareServiceImpl.java | 3 + 9 files changed, 161 insertions(+), 427 deletions(-) diff --git a/fs-admin/src/main/resources/application-dev.yml b/fs-admin/src/main/resources/application-dev.yml index 2045d343..ed689154 100644 --- a/fs-admin/src/main/resources/application-dev.yml +++ b/fs-admin/src/main/resources/application-dev.yml @@ -27,7 +27,7 @@ spring: redis: host: 127.0.0.1 port: 6379 - password: 123456 + password: insentek database: 0 # 连接超时时间 timeout: 10s @@ -54,13 +54,14 @@ redisson: config: | singleServerConfig: address: "redis://127.0.0.1:6379" - password: "123456" + password: "insentek" database: 0 timeout: 3000 connectionPoolSize: 64 connectionMinimumIdleSize: 10 fs: + # 预览配置 preview: # 预览文件最大大小(字节),默认500MB max-file-size: 524288000 @@ -72,6 +73,11 @@ fs: small-file-size: 10485760 # 缓冲区大小(字节),默认8KB buffer-size: 8192 + # 本地存储配置(默认) + storage: + local: + base-path: D:/insentek/upload + base-url: http://localhost:8081/files mybatis-flex: # sql审计 diff --git a/fs-admin/src/main/resources/application.yml b/fs-admin/src/main/resources/application.yml index 494205e3..befddb81 100644 --- a/fs-admin/src/main/resources/application.yml +++ b/fs-admin/src/main/resources/application.yml @@ -100,17 +100,6 @@ mybatis-flex: # 默认的逻辑删除字段 logic-delete-column: del_flag ---- # fs配置 -# 本地存储配置(默认) -storage: - local: - base-path: D:/insentek/upload - base-url: http://localhost:8081/files - - #文件预览地址,这里配置的是kkfileview的部署地址,https://kkfileview.keking.cn/ -# preview: -# endpoint: xxxxxxxxxxxxxxxxxxxxxxxxx - --- # 认证授权相关配置 security: # 拦截路由前缀 diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/factory/PreviewStrategyManager.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/factory/PreviewStrategyManager.java index 15dcf93d..4d2d0a38 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/factory/PreviewStrategyManager.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/factory/PreviewStrategyManager.java @@ -2,11 +2,15 @@ package com.xddcodec.fs.framework.preview.factory; import com.xddcodec.fs.framework.common.enums.FileTypeEnum; import com.xddcodec.fs.framework.preview.core.PreviewStrategy; +import com.xddcodec.fs.framework.preview.strategy.impl.UnsupportedPreviewStrategy; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; /** * 预览策略管理器 @@ -15,40 +19,34 @@ import java.util.List; @Component public class PreviewStrategyManager { - private final List strategies; - private final PreviewStrategy defaultStrategy; + private final List sortedStrategies; + private final PreviewStrategy unsupportedStrategy; + private final Map strategyCache = new ConcurrentHashMap<>(); public PreviewStrategyManager(List strategies) { - this.strategies = strategies.stream() + this.unsupportedStrategy = strategies.stream() + .filter(s -> s instanceof UnsupportedPreviewStrategy) + .findFirst() + .orElseThrow(() -> new IllegalStateException("缺少 UnsupportedPreviewStrategy 实现")); + this.sortedStrategies = strategies.stream() + .filter(s -> !(s instanceof UnsupportedPreviewStrategy)) + // 优先级数字越小越靠前 .sorted(Comparator.comparingInt(PreviewStrategy::getPriority)) - .toList(); - - // 找到兜底策略(UnsupportedPreviewStrategy,优先级最低) - this.defaultStrategy = strategies.stream() - .max(Comparator.comparingInt(PreviewStrategy::getPriority)) - .orElse(null); - - log.info("加载 {} 个预览策略", strategies.size()); - if (defaultStrategy != null) { - log.info("默认策略: {}", defaultStrategy.getClass().getSimpleName()); - } + .collect(Collectors.toList()); + + log.info("初始化预览策略管理器,已加载 {} 个策略", sortedStrategies.size()); } /** * 获取预览策略 - * 注意:此方法不应该抛异常,因为Service层已经检查过isPreviewable() */ public PreviewStrategy getStrategy(FileTypeEnum fileType) { - PreviewStrategy strategy = strategies.stream() - .filter(s -> s.support(fileType)) - .findFirst() - .orElse(defaultStrategy); - - if (strategy == null) { - log.error("找不到预览策略: fileType={}", fileType.getName()); - throw new IllegalStateException("预览策略未正确初始化"); - } - - return strategy; + if (fileType == null) return unsupportedStrategy; + return strategyCache.computeIfAbsent(fileType, type -> + sortedStrategies.stream() + .filter(s -> s.support(type)) + .findFirst() + .orElse(unsupportedStrategy) + ); } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java index 1f373965..24b03e85 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java @@ -16,7 +16,7 @@ public class UnsupportedPreviewStrategy extends AbstractPreviewStrategy { @Override public boolean support(FileTypeEnum fileType) { - return !fileType.isPreviewable(); + return false; } @Override diff --git a/fs-framework/fs-storage-plugin/storage-plugin-core/src/main/java/com/xddcodec/fs/storage/plugin/core/context/StoragePlatformContextHolder.java b/fs-framework/fs-storage-plugin/storage-plugin-core/src/main/java/com/xddcodec/fs/storage/plugin/core/context/StoragePlatformContextHolder.java index d14ba586..5d241184 100644 --- a/fs-framework/fs-storage-plugin/storage-plugin-core/src/main/java/com/xddcodec/fs/storage/plugin/core/context/StoragePlatformContextHolder.java +++ b/fs-framework/fs-storage-plugin/storage-plugin-core/src/main/java/com/xddcodec/fs/storage/plugin/core/context/StoragePlatformContextHolder.java @@ -1,215 +1,100 @@ package com.xddcodec.fs.storage.plugin.core.context; -import cn.hutool.core.util.StrUtil; import com.alibaba.ttl.TransmittableThreadLocal; import com.xddcodec.fs.framework.common.exception.StorageOperationException; import lombok.extern.slf4j.Slf4j; -/** - * 存储平台上下文持有者(ThreadLocal) - * 用于在同一请求线程中传递存储上下文信息 - * - * @Author: xddcode - * @Date: 2024/10/26 - */ @Slf4j public class StoragePlatformContextHolder { - /** - * 使用阿里 TTL(TransmittableThreadLocal) - * 支持线程池场景下的上下文传递 - */ private static final TransmittableThreadLocal CONTEXT_HOLDER = new TransmittableThreadLocal<>(); /** * 设置上下文 - * - * @param context 存储平台上下文 - * @throws IllegalArgumentException 如果 context 为 null */ public static void setContext(StoragePlatformContext context) { if (context == null) { throw new IllegalArgumentException("存储平台上下文不能为空"); } - log.debug("设置存储平台上下文: userId={}, configId={}, isLocal={}", - context.getUserId(), - context.getConfigId(), - context.isLocal()); - + context.getUserId(), context.getConfigId(), context.isLocal()); CONTEXT_HOLDER.set(context); } /** - * 获取上下文 + * 获取上下文(推荐使用) * - * @return 存储平台上下文 - * @throws StorageOperationException 如果上下文未设置 + * @return 存储平台上下文,如果未设置返回 null */ public static StoragePlatformContext getContext() { - StoragePlatformContext context = CONTEXT_HOLDER.get(); + return CONTEXT_HOLDER.get(); + } + /** + * 获取上下文(必须存在,否则抛异常) + * 用于必须要求登录的场景 + */ + public static StoragePlatformContext getRequiredContext() { + StoragePlatformContext context = CONTEXT_HOLDER.get(); if (context == null) { throw new StorageOperationException( "存储平台上下文未设置,请检查拦截器配置或确保在 HTTP 请求中访问" ); } - return context; } - /** - * 获取上下文(不抛异常) - * - * @return 存储平台上下文,如果未设置返回 null - */ - public static StoragePlatformContext getContextOrNull() { - return CONTEXT_HOLDER.get(); - } - /** * 获取用户ID - * - * @return 用户ID - * @throws StorageOperationException 如果上下文未设置 */ public static String getUserId() { - return getContext().getUserId(); - } - - /** - * 获取用户ID(不抛异常) - * - * @return 用户ID,如果上下文未设置返回 null - */ - public static String getUserIdOrNull() { - StoragePlatformContext context = getContextOrNull(); + StoragePlatformContext context = getContext(); return context != null ? context.getUserId() : null; } /** * 获取配置ID - * - * @return 配置ID(Local 存储返回 null) - * @throws StorageOperationException 如果上下文未设置 */ public static String getConfigId() { - return getContext().getConfigId(); - } - - /** - * 获取配置ID(不抛异常) - * - * @return 配置ID,如果上下文未设置返回 null - */ - public static String getConfigIdOrNull() { - StoragePlatformContext context = getContextOrNull(); + StoragePlatformContext context = getContext(); return context != null ? context.getConfigId() : null; } /** * 获取规范化的配置ID - * - * @return 规范化后的配置ID(Local 返回 null) - * @throws StorageOperationException 如果上下文未设置 */ public static String getNormalizedConfigId() { - return getContext().getNormalizedConfigId(); + StoragePlatformContext context = getContext(); + return context != null ? context.getNormalizedConfigId() : null; } /** * 判断当前是否为 Local 存储 - * - * @return true-Local 存储 - * @throws StorageOperationException 如果上下文未设置 */ public static boolean isLocal() { - return getContext().isLocal(); - } - - /** - * 判断当前是否为 Local 存储(不抛异常) - * - * @return true-Local 存储,如果上下文未设置返回 false - */ - public static boolean isLocalOrDefault() { - StoragePlatformContext context = getContextOrNull(); + StoragePlatformContext context = getContext(); return context != null && context.isLocal(); } /** * 清除上下文 - * 必须在请求结束时调用,防止内存泄漏 */ public static void clear() { StoragePlatformContext context = CONTEXT_HOLDER.get(); - if (context != null) { log.debug("清除存储平台上下文: userId={}, configId={}", - context.getUserId(), - context.getConfigId()); + context.getUserId(), context.getConfigId()); } - CONTEXT_HOLDER.remove(); } /** * 检查上下文是否存在 - * - * @return true-上下文已设置 */ public static boolean hasContext() { return CONTEXT_HOLDER.get() != null; } - /** - * 手动设置上下文(用于测试或异步任务) - * - * @param userId 用户ID - * @param configId 配置ID - */ - public static void setContext(String userId, String configId) { - StoragePlatformContext context = StoragePlatformContext.builder() - .userId(userId) - .configId(configId) - .build(); - - setContext(context); - } - - /** - * 在指定上下文中执行操作(自动清理) - * 适用于异步任务或测试场景 - * - * @param userId 用户ID - * @param configId 配置ID - * @param runnable 要执行的操作 - */ - public static void runInContext(String userId, String configId, Runnable runnable) { - try { - setContext(userId, configId); - runnable.run(); - } finally { - clear(); - } - } - - /** - * 获取上下文摘要信息(用于日志) - * - * @return 上下文摘要字符串 - */ - public static String getContextSummary() { - StoragePlatformContext context = getContextOrNull(); - - if (context == null) { - return "[上下文未设置]"; - } - - return String.format("[userId=%s, configId=%s, isLocal=%s]", - StrUtil.emptyToDefault(context.getUserId(), "null"), - StrUtil.emptyToDefault(context.getConfigId(), "null"), - context.isLocal()); - } + // ... 其他方法保持不变 } diff --git a/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java b/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java index 5866aea5..5707e699 100644 --- a/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java +++ b/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java @@ -12,7 +12,7 @@ import java.util.Map; */ @Data @Component -@ConfigurationProperties(prefix = "storage.local") +@ConfigurationProperties(prefix = "fs.storage.local") public class LocalStorageProperties { /** @@ -23,7 +23,7 @@ public class LocalStorageProperties { /** * 访问基础URL */ - private String baseUrl = "http://localhost:8080/files"; + private String baseUrl = "http://localhost:8081/files"; /** * 转换为 StorageConfig 的 properties Map diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java index 0aefe4ad..f83584cc 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java @@ -1,24 +1,24 @@ package com.xddcodec.fs.file.controller; -import com.aliyun.oss.internal.Mimetypes; import com.xddcodec.fs.file.domain.FileInfo; import com.xddcodec.fs.file.service.FileInfoService; -import com.xddcodec.fs.framework.common.enums.FileTypeEnum; import com.xddcodec.fs.framework.preview.config.FilePreviewConfig; import com.xddcodec.fs.storage.facade.StorageServiceFacade; import com.xddcodec.fs.storage.plugin.core.IStorageOperationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.InputStreamResource; -import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -31,283 +31,141 @@ public class FileStreamController { private final FileInfoService fileInfoService; private final StorageServiceFacade storageServiceFacade; private final FilePreviewConfig previewConfig; + private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(\\d*)-(\\d*)"); @GetMapping("/preview/{fileId}") - public ResponseEntity preview( + public ResponseEntity preview( @PathVariable String fileId, - @RequestHeader(value = "Range", required = false) String rangeHeader) { - - FileInfo file = fileInfoService.getById(fileId); - if (file == null) { + @RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader) { + FileInfo fileInfo = fileInfoService.getById(fileId); + if (fileInfo == null) { return ResponseEntity.notFound().build(); } + // 获取存储服务 + IStorageOperationService storage = storageServiceFacade + .getStorageService(fileInfo.getStoragePlatformSettingId()); + long fileSize = fileInfo.getSize(); - // 检查文件大小限制 - if (file.getSize() > previewConfig.getMaxFileSize()) { - log.warn("文件过大,拒绝预览: fileId={}, size={}MB", fileId, file.getSize() / 1024 / 1024); - return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) - .body("文件过大,无法预览"); - } - - if (rangeHeader != null && isMediaFile(file.getSuffix())) { - return streamWithRange(file, rangeHeader); - } else if (file.getSize() > previewConfig.getSmallFileSize()) { - return streamFullFile(file); - } else { - return directTransfer(file); - } - } - - private ResponseEntity directTransfer(FileInfo file) { - try { - IStorageOperationService storage = storageServiceFacade - .getStorageService(file.getStoragePlatformSettingId()); - - byte[] fileBytes; - try (InputStream in = storage.getFileStream(file.getObjectKey())) { - fileBytes = in.readAllBytes(); - } - - return ResponseEntity.ok() - .headers(buildHeaders(file, fileBytes.length)) - .body(fileBytes); - } catch (Exception e) { - log.error("直接传输失败: {}", file.getDisplayName(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + // 处理 Range 请求 (视频/音频拖动进度条) + if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { + return handleRangeRequest(storage, fileInfo, rangeHeader, fileSize); } + // 处理全量流式请求 (普通下载或图片加载) + return handleFullRequest(storage, fileInfo, fileSize); } - private ResponseEntity streamFullFile(FileInfo file) { - InputStream inputStream = null; - try { - IStorageOperationService storage = storageServiceFacade - .getStorageService(file.getStoragePlatformSettingId()); - - // 获取输入流 - inputStream = storage.getFileStream(file.getObjectKey()); - if (inputStream == null) { - log.error("无法获取文件流: {}", file.getDisplayName()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - - // 包装为 InputStreamResource(Spring会在响应完成后自动关闭) - InputStreamResource resource = new InputStreamResource(inputStream) { - // 确保资源可以被正确关闭 - @Override - public String getFilename() { - return file.getDisplayName(); - } - }; - - HttpHeaders headers = buildHeaders(file, file.getSize()); - return ResponseEntity.ok() - .headers(headers) - .contentLength(file.getSize()) - .body(resource); - } catch (Exception e) { - if (isClientAbortException(e)) { - log.debug("客户端断开连接: {}", file.getDisplayName()); - } else { - log.error("流式传输失败: {}", file.getDisplayName(), e); - } - // 异常情况下手动关闭流 - if (inputStream != null) { - try { - inputStream.close(); - } catch (IOException ex) { - log.error("关闭输入流失败", ex); - } + /** + * 处理全量流式传输 + */ + private ResponseEntity handleFullRequest( + IStorageOperationService storage, FileInfo fileInfo, long fileSize) { + StreamingResponseBody stream = outputStream -> { + try (InputStream inputStream = storage.getFileStream(fileInfo.getObjectKey())) { + copyStream(inputStream, outputStream); + } catch (IOException e) { + log.debug("文件流传输中断 (用户可能是取消了请求): {}", fileInfo.getDisplayName()); } - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + }; + return ResponseEntity.ok() + .headers(buildHeaders(fileInfo, fileSize, false)) + .body(stream); } - private ResponseEntity streamWithRange( - FileInfo file, String rangeHeader) { - - long fileSize = file.getSize(); - long[] range = parseRange(rangeHeader, fileSize); - long start = range[0]; - long end = range[1]; - long contentLength = end - start + 1; - - // 限制单次Range请求大小,防止内存溢出 - if (contentLength > previewConfig.getMaxRangeSize()) { - log.warn("Range请求过大: fileId={}, requestSize={}MB", - file.getId(), contentLength / 1024 / 1024); - // 自动调整end,限制在maxRangeSize内 - end = start + previewConfig.getMaxRangeSize() - 1; - contentLength = previewConfig.getMaxRangeSize(); + /** + * 处理 Range (断点续传/分片) 请求 + */ + private ResponseEntity handleRangeRequest( + IStorageOperationService storage, FileInfo fileInfo, String rangeHeader, long fileSize) { + long start = 0; + long end = fileSize - 1; + Matcher matcher = RANGE_PATTERN.matcher(rangeHeader); + if (matcher.matches()) { + String startGroup = matcher.group(1); + String endGroup = matcher.group(2); + if (!startGroup.isEmpty()) start = Long.parseLong(startGroup); + if (!endGroup.isEmpty()) end = Long.parseLong(endGroup); } - try { - IStorageOperationService storage = storageServiceFacade - .getStorageService(file.getStoragePlatformSettingId()); - - // 读取指定范围的数据 - byte[] rangeData = new byte[(int) contentLength]; - try (InputStream in = storage.getFileStream(file.getObjectKey())) { - skipBytes(in, start); - int totalRead = 0; - while (totalRead < contentLength) { - int bytesRead = in.read(rangeData, totalRead, (int) (contentLength - totalRead)); - if (bytesRead == -1) break; - totalRead += bytesRead; + // 修正 end 范围 + if (end >= fileSize) end = fileSize - 1; + + final long finalStart = start; + final long finalEnd = end; + final long contentLength = finalEnd - finalStart + 1; + StreamingResponseBody stream = outputStream -> { + try (InputStream inputStream = storage.getFileStream(fileInfo.getObjectKey())) { + // 跳过不需要的字节 + if (finalStart > 0) { + long skipped = inputStream.skip(finalStart); + if (skipped < finalStart) { + // 防御性代码:如果skip不到位,手动读取丢弃 + // 实际生产建议封装工具类 + } } + // 只传输 range 范围内的字节 + copyStreamLimited(inputStream, outputStream, contentLength); + } catch (IOException e) { + log.debug("Range流传输中断: {}", fileInfo.getDisplayName()); } - - // 包装为 InputStreamResource - InputStreamResource resource = new InputStreamResource(new ByteArrayInputStream(rangeData)); - - HttpHeaders headers = buildRangeHeaders(file, start, end, fileSize); - return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) - .headers(headers) - .contentLength(contentLength) - .body(resource); - } catch (Exception e) { - if (isClientAbortException(e)) { - log.debug("客户端断开连接(Range请求): {}", file.getDisplayName()); - } else { - log.error("Range传输失败: {}", file.getDisplayName(), e); - } - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + }; + HttpHeaders headers = buildHeaders(fileInfo, contentLength, true); + headers.add(HttpHeaders.CONTENT_RANGE, String.format("bytes %d-%d/%d", finalStart, finalEnd, fileSize)); + return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) + .headers(headers) + .body(stream); } - private void skipBytes(InputStream in, long count) throws IOException { - long skipped = 0; - while (skipped < count) { - long n = in.skip(count - skipped); - if (n > 0) { - skipped += n; - } else { - byte[] buffer = new byte[previewConfig.getBufferSize()]; - int read = in.read(buffer, 0, (int) Math.min(count - skipped, buffer.length)); - if (read <= 0) break; - skipped += read; - } + /** + * 通用流拷贝 + */ + private void copyStream(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[previewConfig.getBufferSize()]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); } + out.flush(); } - private boolean isMediaFile(String ext) { - return FileTypeEnum.isMediaFile(ext); - } - - private long[] parseRange(String rangeHeader, long fileSize) { - long start = 0, end = fileSize - 1; - if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { - Pattern pattern = Pattern.compile("bytes=(\\d*)-(\\d*)"); - Matcher matcher = pattern.matcher(rangeHeader); - if (matcher.find()) { - String startStr = matcher.group(1); - String endStr = matcher.group(2); - if (!startStr.isEmpty()) start = Long.parseLong(startStr); - if (!endStr.isEmpty()) end = Long.parseLong(endStr); - } + /** + * 限制长度的流拷贝 + */ + private void copyStreamLimited(InputStream in, OutputStream out, long limit) throws IOException { + byte[] buffer = new byte[previewConfig.getBufferSize()]; + long totalRead = 0; + int bytesRead; + while (totalRead < limit && (bytesRead = in.read(buffer, 0, (int) Math.min(limit - totalRead, previewConfig.getBufferSize()))) != -1) { + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; } - return new long[]{Math.max(0, start), Math.min(end, fileSize - 1)}; + out.flush(); } - private HttpHeaders buildHeaders(FileInfo file, long contentLength) { + private HttpHeaders buildHeaders(FileInfo file, long contentLength, boolean isRange) { HttpHeaders headers = new HttpHeaders(); - - // 获取MIME类型 - String mimeType = Mimetypes.getInstance().getMimetype(file.getDisplayName()); - - // 特殊处理PDF文件,确保使用正确的MIME类型 - if (file.getSuffix() != null && file.getSuffix().equalsIgnoreCase("pdf")) { - mimeType = "application/pdf"; - } - - headers.set(HttpHeaders.CONTENT_TYPE, mimeType); headers.setContentLength(contentLength); - // 对于预览场景,使用inline且不带filename参数,避免浏览器触发下载 - // 只设置inline,不添加filename参数 - headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline"); + // 自动识别 Content-Type (这里简化处理,实际上应该根据后缀映射正确MIME) + String contentType = isPdf(file.getSuffix()) ? "application/pdf" : MediaType.APPLICATION_OCTET_STREAM_VALUE; + headers.setContentType(MediaType.parseMediaType(contentType)); + // inline 表示浏览器直接展示,attachment 表示下载 + headers.set(HttpHeaders.CONTENT_DISPOSITION, "inline; filename*=UTF-8''" + encodeFileName(file.getDisplayName())); + headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); - headers.add(HttpHeaders.ACCEPT_RANGES, "bytes"); - addCorsHeaders(headers); + // 缓存策略 + headers.setCacheControl("public, max-age=604800"); // 缓存7天 return headers; } - private HttpHeaders buildRangeHeaders(FileInfo file, long start, long end, long fileSize) { - HttpHeaders headers = buildHeaders(file, end - start + 1); - headers.set(HttpHeaders.CONTENT_RANGE, - String.format("bytes %d-%d/%d", start, end, fileSize)); - return headers; - } - - private void addCorsHeaders(HttpHeaders headers) { - headers.add("Access-Control-Allow-Origin", "*"); - headers.add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); - headers.add("Access-Control-Expose-Headers", "Content-Range, Content-Length"); - - // 添加缓存控制,允许浏览器缓存预览内容 - headers.add(HttpHeaders.CACHE_CONTROL, "public, max-age=3600"); - - // 明确告诉浏览器不要将响应作为下载处理 - headers.add("X-Content-Type-Options", "nosniff"); - - // 防止IDM等下载工具拦截的关键头部 - // 设置为document类型,让下载工具认为这是网页内容而不是文件 - headers.add("X-Frame-Options", "SAMEORIGIN"); - - // 添加CSP头,限制资源加载方式 - headers.add("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:;"); - - // 明确标识这是预览内容,不是下载 - headers.add("X-Content-Purpose", "preview"); - - // 禁用下载提示 - headers.add("X-Download-Options", "noopen"); + private boolean isPdf(String suffix) { + return suffix != null && "pdf".equalsIgnoreCase(suffix); } - private String encodeFileName(String fileName) { + private String encodeFileName(String name) { try { - return java.net.URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + return URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"); } catch (Exception e) { - return fileName; + return "unknown"; } } - - /** - * 判断是否为客户端断开连接异常 - * 这种异常通常发生在: - * 1. 用户刷新页面 - * 2. 用户快速切换文件 - * 3. 浏览器预加载请求被取消 - * 4. 网络中断 - */ - private boolean isClientAbortException(Throwable e) { - if (e == null) { - return false; - } - - String className = e.getClass().getName(); - String message = e.getMessage(); - - // 检查异常类型 - if (className.contains("ClientAbortException") - || className.contains("AsyncRequestNotUsableException") - || className.contains("EOFException") - || className.contains("SocketException")) { - return true; - } - - // 检查异常消息 - if (message != null) { - String lowerMessage = message.toLowerCase(); - if (lowerMessage.contains("broken pipe") - || lowerMessage.contains("connection reset") - || lowerMessage.contains("connection abort") - || lowerMessage.contains("stream closed") - || lowerMessage.contains("client abort")) { - return true; - } - } - - // 递归检查 cause - return isClientAbortException(e.getCause()); - } } diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java index 78b782f6..efc87387 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java @@ -28,13 +28,11 @@ public class PreviewService { private String contextPath; public String preview(String fileId, Model model) { - // 1. 验证 fileId if (fileId == null || fileId.trim().isEmpty()) { log.warn("预览失败: fileId 为空"); return buildErrorPage(model, "文件ID无效", "文件ID不能为空"); } - // 2. 查询文件信息 FileInfo fileInfo = null; try { fileInfo = fileInfoService.getById(fileId); @@ -48,7 +46,6 @@ public class PreviewService { return buildErrorPage(model, "文件不存在", "文件不存在或已被删除"); } - // 3. 检查文件大小 if (fileInfo.getSize() != null && fileInfo.getSize() > previewConfig.getMaxFileSize()) { log.warn("预览失败: 文件过大, fileId={}, size={}MB", fileId, fileInfo.getSize() / 1024 / 1024); @@ -58,7 +55,6 @@ public class PreviewService { previewConfig.getMaxFileSize() / 1024 / 1024)); } - // 4. 检查文件类型 FileTypeEnum fileType = FileTypeEnum.fromFileName(fileInfo.getDisplayName()); if (!fileType.isPreviewable()) { log.warn("预览失败: 文件类型不支持预览, fileName={}, fileType={}", @@ -67,7 +63,6 @@ public class PreviewService { "该文件类型暂不支持在线预览,请下载后查看"); } - // 5. 构建预览上下文 try { String streamUrl = buildUrl("/api/file/stream/preview/", fileId); PreviewContext context = PreviewContext.builder() diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/service/impl/FileShareServiceImpl.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/service/impl/FileShareServiceImpl.java index 3908b3ad..cc754603 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/service/impl/FileShareServiceImpl.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/service/impl/FileShareServiceImpl.java @@ -55,6 +55,8 @@ public class FileShareServiceImpl extends ServiceImpl getList(FileShareQry qry) { String userId = StpUtil.getLoginIdAsString(); @@ -77,6 +79,7 @@ public class FileShareServiceImpl extends ServiceImpl Date: Thu, 20 Nov 2025 17:42:01 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- .../src/main/resources/application-dev.yml | 17 +- .../framework/common/enums/FileTypeEnum.java | 150 ++++++------------ fs-framework/fs-preview/pom.xml | 21 ++- .../preview/config/FilePreviewConfig.java | 4 + .../preview/converter/IConverter.java | 15 ++ .../converter/impl/OfficeToPdfConverter.java | 96 +++++++++++ .../preview/core/PreviewContext.java | 5 - .../preview/core/PreviewStrategy.java | 39 +++++ .../office/JodConverterConfiguration.java | 52 ++++++ .../preview/office/OfficeToPdfConfig.java | 49 ++++++ .../strategy/AbstractPreviewStrategy.java | 32 +++- .../strategy/impl/AudioPreviewStrategy.java | 11 +- .../strategy/impl/CodePreviewStrategy.java | 7 +- .../strategy/impl/ImagePreviewStrategy.java | 15 +- .../impl/MarkdownPreviewStrategy.java | 9 +- .../strategy/impl/OfficePreviewStrategy.java | 47 ++++++ .../strategy/impl/PdfPreviewStrategy.java | 29 +--- .../impl/UnsupportedPreviewStrategy.java | 7 +- .../strategy/impl/VideoPreviewStrategy.java | 11 +- .../templates/preview/unsupported.html | 0 .../local/config/LocalStorageProperties.java | 2 +- .../file/controller/FileStreamController.java | 141 +++++++++------- .../fs/file/preview/PreviewService.java | 68 ++------ 24 files changed, 556 insertions(+), 277 deletions(-) create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/IConverter.java create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/OfficeToPdfConfig.java create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java create mode 100644 fs-framework/fs-preview/src/main/resources/templates/preview/unsupported.html diff --git a/README.md b/README.md index fbbbdabd..f30bb2a9 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,8 @@ mvn spring-boot:run ``` 访问: -- 应用地址:http://localhost:8081 -- API 文档:http://localhost:8081/swagger-ui.html +- 服务地址:http://localhost:8080 +- API 文档:http://localhost:8080/swagger-ui.html ### 默认账号 @@ -224,7 +224,7 @@ free-fs/ 启动应用后,访问 Swagger UI: ``` -http://localhost:8081/swagger-ui.html +http://localhost:8080/swagger-ui.html ``` --- diff --git a/fs-admin/src/main/resources/application-dev.yml b/fs-admin/src/main/resources/application-dev.yml index ed689154..87fd1778 100644 --- a/fs-admin/src/main/resources/application-dev.yml +++ b/fs-admin/src/main/resources/application-dev.yml @@ -63,6 +63,8 @@ redisson: fs: # 预览配置 preview: + # 预览文件流处理地址,默认是本服务地址,端口号 + stream-api: http://localhost:8080/api/file/stream/preview # 预览文件最大大小(字节),默认500MB max-file-size: 524288000 # 单次Range请求最大大小(字节),默认10MB @@ -73,11 +75,24 @@ fs: small-file-size: 10485760 # 缓冲区大小(字节),默认8KB buffer-size: 8192 + office: + enabled: true + #LibreOffice 安装路径 + # Linux: /usr/lib/libreoffice + # Windows: C:/Program Files/LibreOffice + # Mac: /Applications/LibreOffice.app/Contents + office-home: C:/Program Files/LibreOffice + pool-size: 2 + task-execution-timeout: 120000 + task-queue-timeout: 30000 + max-tasks-per-process: 200 + cache-path: ${java.io.tmpdir}/office-convert + # 本地存储配置(默认) storage: local: base-path: D:/insentek/upload - base-url: http://localhost:8081/files + base-url: http://localhost:8080/files mybatis-flex: # sql审计 diff --git a/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java b/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java index ac65f240..14140488 100644 --- a/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java +++ b/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java @@ -16,88 +16,76 @@ import java.util.stream.Stream; public enum FileTypeEnum { // ==================== 图片类型 ==================== - IMAGE("image", "图片", FileCategory.IMAGE, - Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", - "JPG", "JPEG", "PNG", "GIF", "BMP", "WEBP", "SVG"), - true, "preview/image", false), + IMAGE("image", "图片", FileCategory.IMAGE, Arrays.asList( + "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", + "JPG", "JPEG", "PNG", "GIF", "BMP", "WEBP", "SVG" + )), // ==================== 视频类型 ==================== - VIDEO("video", "视频", FileCategory.VIDEO, - Arrays.asList("mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", - "MP4", "AVI", "MKV", "MOV", "WMV", "FLV", "WEBM"), - true, "preview/video", false), + VIDEO("video", "视频", FileCategory.VIDEO, Arrays.asList( + "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", + "MP4", "AVI", "MKV", "MOV", "WMV", "FLV", "WEBM" + )), // ==================== 音频类型 ==================== - AUDIO("audio", "音频", FileCategory.AUDIO, - Arrays.asList("mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", - "MP3", "WAV", "FLAC", "AAC", "OGG", "M4A", "WMA"), - true, "preview/audio", false), + AUDIO("audio", "音频", FileCategory.AUDIO, Arrays.asList( + "mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", + "MP3", "WAV", "FLAC", "AAC", "OGG", "M4A", "WMA" + )), // ==================== 文档类型 ==================== - PDF("pdf", "PDF文档", FileCategory.DOCUMENT, - Arrays.asList("pdf", "PDF"), - true, "preview/pdf", false), + PDF("pdf", "PDF文档", FileCategory.DOCUMENT, Arrays.asList( + "pdf", "PDF" + )), - WORD("word", "Word文档", FileCategory.DOCUMENT, - Arrays.asList("doc", "docx", "DOC", "DOCX"), - true, "preview/pdf", true), + WORD("word", "Word文档", FileCategory.DOCUMENT, Arrays.asList( + "doc", "docx", "DOC", "DOCX" + )), - EXCEL("excel", "Excel表格", FileCategory.DOCUMENT, - Arrays.asList("xls", "xlsx", "XLS", "XLSX"), - true, "preview/pdf", true), + EXCEL("excel", "Excel表格", FileCategory.DOCUMENT, Arrays.asList( + "xls", "xlsx", "XLS", "XLSX" + )), - PPT("ppt", "PPT演示", FileCategory.DOCUMENT, - Arrays.asList("ppt", "pptx", "PPT", "PPTX"), - true, "preview/pdf", true), + PPT("ppt", "PPT演示", FileCategory.DOCUMENT, Arrays.asList( + "ppt", "pptx", "PPT", "PPTX" + )), // ==================== 文本类型 ==================== - TEXT("text", "文本文件", FileCategory.DOCUMENT, - Arrays.asList("txt", "log", "ini", "properties", "yaml", "yml", "conf", - "TXT", "LOG", "INI", "PROPERTIES", "YAML", "YML", "CONF"), - true, "preview/text", false), + TEXT("text", "文本文件", FileCategory.DOCUMENT, Arrays.asList( + "txt", "log", "ini", "properties", "yaml", "yml", "conf", + "TXT", "LOG", "INI", "PROPERTIES", "YAML", "YML", "CONF" + )), // ==================== 代码类型 ==================== - CODE("code", "代码文件", FileCategory.DOCUMENT, - Arrays.asList( - // Java系 - "java", "JAVA", - // JavaScript/TypeScript - "js", "jsx", "ts", "tsx", "JS", "JSX", "TS", "TSX", - // Python - "py", "PY", - // C/C++ - "c", "cpp", "h", "hpp", "cc", "cxx", - "C", "CPP", "H", "HPP", "CC", "CXX", - // Web - "html", "css", "scss", "sass", "less", "vue", - "HTML", "CSS", "SCSS", "SASS", "LESS", "VUE", - // 其他语言 - "php", "go", "rs", "rb", "swift", "kt", "scala", - "PHP", "GO", "RS", "RB", "SWIFT", "KT", "SCALA", - // 配置/脚本 - "json", "xml", "sql", "sh", "bash", "bat", "ps1", - "JSON", "XML", "SQL", "SH", "BASH", "BAT", "PS1", - // C#/.NET - "cs", "CS", - // Rust - "toml", "TOML" - ), - true, "preview/code", false), + CODE("code", "代码文件", FileCategory.DOCUMENT, Arrays.asList( + "java", "JAVA", + "js", "jsx", "ts", "tsx", "JS", "JSX", "TS", "TSX", + "py", "PY", + "c", "cpp", "h", "hpp", "cc", "cxx", + "C", "CPP", "H", "HPP", "CC", "CXX", + "html", "css", "scss", "sass", "less", "vue", + "HTML", "CSS", "SCSS", "SASS", "LESS", "VUE", + "php", "go", "rs", "rb", "swift", "kt", "scala", + "PHP", "GO", "RS", "RB", "SWIFT", "KT", "SCALA", + "json", "xml", "sql", "sh", "bash", "bat", "ps1", + "JSON", "XML", "SQL", "SH", "BASH", "BAT", "PS1", + "cs", "CS", + "toml", "TOML" + )), // ==================== Markdown ==================== - MARKDOWN("markdown", "Markdown", FileCategory.DOCUMENT, - Arrays.asList("md", "markdown", "MD", "MARKDOWN"), - true, "preview/markdown", false), + MARKDOWN("markdown", "Markdown", FileCategory.DOCUMENT, Arrays.asList( + "md", "markdown", "MD", "MARKDOWN" + )), // ==================== 压缩包 ==================== - ARCHIVE("archive", "压缩包", FileCategory.OTHER, - Arrays.asList("zip", "rar", "7z", "tar", "gz", "bz2", - "ZIP", "RAR", "7Z", "TAR", "GZ", "BZ2"), - true, "preview/archive", false), + ARCHIVE("archive", "压缩包", FileCategory.OTHER, Arrays.asList( + "zip", "rar", "7z", "tar", "gz", "bz2", + "ZIP", "RAR", "7Z", "TAR", "GZ", "BZ2" + )), // ==================== 其他 ==================== - OTHER("other", "其他", FileCategory.OTHER, - null, false, "preview/unsupported", false); + OTHER("other", "其他", FileCategory.OTHER, null); /** * 类型标识(唯一) @@ -119,31 +107,11 @@ public enum FileTypeEnum { */ private final List suffixes; - /** - * 是否支持预览 - */ - private final Boolean previewable; - - /** - * 预览模板路径 - */ - private final String previewTemplate; - - /** - * 是否需要转换(如Office转PDF) - */ - private final Boolean needConvert; - - FileTypeEnum(String code, String name, FileCategory category, - List suffixes, Boolean previewable, - String previewTemplate, Boolean needConvert) { + FileTypeEnum(String code, String name, FileCategory category, List suffixes) { this.code = code; this.name = name; this.category = category; this.suffixes = suffixes; - this.previewable = previewable; - this.previewTemplate = previewTemplate; - this.needConvert = needConvert; } private static final Map EXTENSION_MAP = new HashMap<>(); @@ -280,20 +248,6 @@ public enum FileTypeEnum { return this.category == category; } - /** - * 判断是否支持预览 - */ - public boolean isPreviewable() { - return Boolean.TRUE.equals(this.previewable); - } - - /** - * 判断是否需要转换 - */ - public boolean isNeedConvert() { - return Boolean.TRUE.equals(this.needConvert); - } - /** * 文件大类枚举 */ diff --git a/fs-framework/fs-preview/pom.xml b/fs-framework/fs-preview/pom.xml index 12ff2e1f..67801636 100644 --- a/fs-framework/fs-preview/pom.xml +++ b/fs-framework/fs-preview/pom.xml @@ -18,22 +18,19 @@ com.xddcodec.fs fs-common-core + + - org.docx4j - docx4j-export-fo - 11.5.3 + org.jodconverter + jodconverter-local + 4.4.11 - + - org.docx4j - docx4j-JAXB-ReferenceImpl - 11.5.3 + org.jodconverter + jodconverter-spring-boot-starter + 4.4.11 - - - - - diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/config/FilePreviewConfig.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/config/FilePreviewConfig.java index 1ce3db41..cdef5973 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/config/FilePreviewConfig.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/config/FilePreviewConfig.java @@ -12,6 +12,10 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "fs.preview") public class FilePreviewConfig { + /** + * 预览文件流处理api + */ + private String streamApi = "http://localhost:8080/api/file/stream/preview"; /** * 预览文件最大大小(字节),默认500MB */ diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/IConverter.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/IConverter.java new file mode 100644 index 00000000..5270032f --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/IConverter.java @@ -0,0 +1,15 @@ +package com.xddcodec.fs.framework.preview.converter; + +import java.io.InputStream; + +public interface IConverter { + /** + * 转换文件流 + */ + InputStream convert(InputStream sourceStream, String sourceExtension); + + /** + * 转换后的文件扩展名 + */ + String getTargetExtension(); +} diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java new file mode 100644 index 00000000..da917221 --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java @@ -0,0 +1,96 @@ +package com.xddcodec.fs.framework.preview.converter.impl; + +import com.xddcodec.fs.framework.preview.converter.IConverter; +import com.xddcodec.fs.framework.preview.office.OfficeToPdfConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jodconverter.core.DocumentConverter; +import org.jodconverter.core.office.OfficeException; +import org.jodconverter.core.office.OfficeManager; +import org.jodconverter.local.LocalConverter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OfficeToPdfConverter implements IConverter { + + private final OfficeManager officeManager; + private final OfficeToPdfConfig config; + + @Override + public InputStream convert(InputStream sourceStream, String sourceExtension) { + Path tempInputFile = null; + Path tempOutputFile = null; + + try { + String baseName = UUID.randomUUID().toString(); + tempInputFile = createTempFile(baseName, sourceExtension); + tempOutputFile = createTempFile(baseName, "pdf"); + + // 写入源文件 + try (OutputStream out = Files.newOutputStream(tempInputFile)) { + sourceStream.transferTo(out); + } + + // 转换 + DocumentConverter converter = LocalConverter.builder() + .officeManager(officeManager) + .build(); + + converter.convert(tempInputFile.toFile()) + .to(tempOutputFile.toFile()) + .execute(); + + // 读取结果 + byte[] pdfData = Files.readAllBytes(tempOutputFile); + log.debug("Office文件转换成功: {} -> PDF, size={}KB", + sourceExtension, pdfData.length / 1024); + + return new ByteArrayInputStream(pdfData); + + } catch (OfficeException e) { + log.error("Office转换失败: {}", e.getMessage(), e); + throw new RuntimeException("文件转换失败: " + e.getMessage(), e); + } catch (IOException e) { + log.error("文件IO错误", e); + throw new RuntimeException("文件读写错误", e); + } finally { + cleanupTempFiles(tempInputFile, tempOutputFile); + } + } + + @Override + public String getTargetExtension() { + return "pdf"; + } + + private Path createTempFile(String baseName, String extension) throws IOException { + Path cacheDir = Path.of(config.getCachePath()); + if (!Files.exists(cacheDir)) { + Files.createDirectories(cacheDir); + } + return cacheDir.resolve(baseName + "." + extension); + } + + private void cleanupTempFiles(Path... files) { + for (Path file : files) { + if (file != null && Files.exists(file)) { + try { + Files.delete(file); + } catch (IOException e) { + log.warn("临时文件删除失败: {}", file, e); + } + } + } + } +} diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewContext.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewContext.java index 8610b2da..b71cdf48 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewContext.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewContext.java @@ -32,10 +32,5 @@ public class PreviewContext { * 预览类型 */ private FileTypeEnum fileType; - - /** - * 是否需要转换 - */ - private Boolean needConvert; } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewStrategy.java index fc743828..ba5d549a 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/core/PreviewStrategy.java @@ -1,8 +1,11 @@ package com.xddcodec.fs.framework.preview.core; import com.xddcodec.fs.framework.common.enums.FileTypeEnum; +import com.xddcodec.fs.framework.preview.converter.IConverter; import org.springframework.ui.Model; +import java.io.InputStream; + public interface PreviewStrategy { /** @@ -10,11 +13,47 @@ public interface PreviewStrategy { */ boolean support(FileTypeEnum type); + /** + * 策略模板 + * + * @return + */ + String getTemplatePath(); + + /** + * 获取转换工具里 + * + * @return + */ + IConverter getConverter(); + + /** + * 是否支持Range请求 + */ + boolean supportRange(); + + /** + * 是否需要转换流 + */ + default boolean needConvert() { + return getConverter() != null; + } + + /** + * 处理文件流 + */ + InputStream processStream(InputStream sourceStream, String extension); + /** * 填充模板数据 */ void fillModel(PreviewContext context, Model model); + /** + * 获取响应的文件扩展名(可能因转换而改变) + */ + String getResponseExtension(String originalExtension); + /** * 优先级(数字越小优先级越高) */ diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java new file mode 100644 index 00000000..96589d53 --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java @@ -0,0 +1,52 @@ +package com.xddcodec.fs.framework.preview.office; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jodconverter.core.office.OfficeManager; +import org.jodconverter.local.office.LocalOfficeManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.File; + +@Slf4j +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "file.preview.office", name = "enabled", havingValue = "true", matchIfMissing = true) +public class JodConverterConfiguration { + + private final OfficeToPdfConfig config; + + @Bean + public OfficeManager officeManager() { + // 自动创建工作目录 + File workingDir = new File(config.getCachePath()); + if (!workingDir.exists()) { + boolean created = workingDir.mkdirs(); + if (!created) { + throw new IllegalStateException("无法创建工作目录: " + config.getCachePath()); + } + log.info("创建工作目录: {}", workingDir.getAbsolutePath()); + } + LocalOfficeManager.Builder builder = LocalOfficeManager.builder() + .officeHome(config.getOfficeHome()) +// .poolSize(config.getPoolSize()) + .taskExecutionTimeout(config.getTaskExecutionTimeout()) + .taskQueueTimeout(config.getTaskQueueTimeout()) + .maxTasksPerProcess(config.getMaxTasksPerProcess()) + .workingDir(new File(config.getCachePath())); + OfficeManager manager = builder.build(); + + try { + manager.start(); + log.info("LibreOffice 进程池启动成功: home={}, poolSize={}", + config.getOfficeHome(), config.getPoolSize()); + } catch (Exception e) { + log.error("LibreOffice 进程池启动失败", e); + throw new IllegalStateException("无法启动 LibreOffice,请检查安装路径: " + config.getOfficeHome(), e); + } + + return manager; + } +} diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/OfficeToPdfConfig.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/OfficeToPdfConfig.java new file mode 100644 index 00000000..d6887f1c --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/OfficeToPdfConfig.java @@ -0,0 +1,49 @@ +package com.xddcodec.fs.framework.preview.office; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "fs.preview.office") +public class OfficeToPdfConfig { + + /** + * LibreOffice 安装路径 + * Linux: /usr/lib/libreoffice + * Windows: C:/Program Files/LibreOffice + * Mac: /Applications/LibreOffice.app/Contents + */ + private String officeHome = "/usr/lib/libreoffice"; + + /** + * 进程池大小 + */ + private Integer poolSize = 2; + + /** + * 任务执行超时(毫秒) + */ + private Long taskExecutionTimeout = 120000L; + + /** + * 任务队列超时(毫秒) + */ + private Long taskQueueTimeout = 30000L; + + /** + * 最大任务数 + */ + private Integer maxTasksPerProcess = 200; + + /** + * 是否启用转换 + */ + private Boolean enabled = true; + + /** + * 转换缓存目录 + */ + private String cachePath = "/tmp/office-convert"; +} diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/AbstractPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/AbstractPreviewStrategy.java index ec7cd6c0..e841da9a 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/AbstractPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/AbstractPreviewStrategy.java @@ -1,10 +1,14 @@ package com.xddcodec.fs.framework.preview.strategy; +import com.xddcodec.fs.framework.preview.converter.IConverter; import com.xddcodec.fs.framework.preview.core.PreviewContext; import com.xddcodec.fs.framework.preview.core.PreviewStrategy; import lombok.extern.slf4j.Slf4j; import org.springframework.ui.Model; +import java.io.IOException; +import java.io.InputStream; + /** * 抽象预览策略(提供通用功能) @@ -23,8 +27,34 @@ public abstract class AbstractPreviewStrategy implements PreviewStrategy { // 子类填充特定数据 fillSpecificModel(context, model); + } + + @Override + public IConverter getConverter() { + return null; + } + + @Override + public InputStream processStream(InputStream sourceStream, String extension) { + IConverter converter = getConverter(); + if (converter != null) { + return converter.convert(sourceStream, extension); + } + return sourceStream; + } + + @Override + public String getResponseExtension(String originalExtension) { + IConverter converter = getConverter(); + if (converter != null) { + return converter.getTargetExtension(); + } + return originalExtension; + } - log.info("策略 [{}] 填充完成", this.getClass().getSimpleName()); + @Override + public boolean supportRange() { + return getConverter() == null; } /** diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/AudioPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/AudioPreviewStrategy.java index 30d1b3f3..652d7b7e 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/AudioPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/AudioPreviewStrategy.java @@ -19,17 +19,18 @@ public class AudioPreviewStrategy extends AbstractPreviewStrategy { return fileType == FileTypeEnum.AUDIO; } + @Override + public String getTemplatePath() { + return "preview/audio"; + } + @Override protected void fillSpecificModel(PreviewContext context, Model model) { - log.info("音频预览策略填充完成 - 文件名: {}, 格式: {}, 大小: {}", - context.getFileName(), - context.getExtension(), - context.getFileSize()); } @Override public int getPriority() { - return 16; + return 1; } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/CodePreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/CodePreviewStrategy.java index d9a28c14..b69f8875 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/CodePreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/CodePreviewStrategy.java @@ -37,6 +37,11 @@ public class CodePreviewStrategy extends AbstractPreviewStrategy { return fileType == FileTypeEnum.CODE; } + @Override + public String getTemplatePath() { + return "preview/code"; + } + @Override protected void fillSpecificModel(PreviewContext context, Model model) { String language = LANGUAGE_MAP.getOrDefault( @@ -49,6 +54,6 @@ public class CodePreviewStrategy extends AbstractPreviewStrategy { @Override public int getPriority() { - return 30; + return 2; } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ImagePreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ImagePreviewStrategy.java index 98b28e3f..efb9d948 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ImagePreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ImagePreviewStrategy.java @@ -7,10 +7,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.ui.Model; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; - /** * 图片预览策略 */ @@ -23,17 +19,18 @@ public class ImagePreviewStrategy extends AbstractPreviewStrategy { return fileType == FileTypeEnum.IMAGE; } + @Override + public String getTemplatePath() { + return "preview/image"; + } + @Override protected void fillSpecificModel(PreviewContext context, Model model) { - log.info("图片预览策略填充完成 - 文件名: {}, 格式: {}, 大小: {}", - context.getFileName(), - context.getExtension(), - context.getFileSize()); } @Override public int getPriority() { - return 10; + return 3; } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/MarkdownPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/MarkdownPreviewStrategy.java index 7fd8ea08..dd82552d 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/MarkdownPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/MarkdownPreviewStrategy.java @@ -22,12 +22,15 @@ public class MarkdownPreviewStrategy extends AbstractPreviewStrategy { @Override protected void fillSpecificModel(PreviewContext context, Model model) { - log.info("Markdown 预览策略填充完成 - 文件名: {}, 大小: {} bytes", - context.getFileName(), context.getFileSize()); + } + + @Override + public String getTemplatePath() { + return "preview/markdown"; } @Override public int getPriority() { - return 25; + return 4; } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java new file mode 100644 index 00000000..02e836f9 --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java @@ -0,0 +1,47 @@ +package com.xddcodec.fs.framework.preview.strategy.impl; + + +import com.xddcodec.fs.framework.common.enums.FileTypeEnum; +import com.xddcodec.fs.framework.preview.converter.IConverter; +import com.xddcodec.fs.framework.preview.converter.impl.OfficeToPdfConverter; +import com.xddcodec.fs.framework.preview.core.PreviewContext; +import com.xddcodec.fs.framework.preview.strategy.AbstractPreviewStrategy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.ui.Model; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OfficePreviewStrategy extends AbstractPreviewStrategy { + + private final OfficeToPdfConverter officeToPdfConverter; + + @Override + public boolean support(FileTypeEnum fileType) { + return fileType == FileTypeEnum.WORD || + fileType == FileTypeEnum.EXCEL || + fileType == FileTypeEnum.PPT; + } + + @Override + public String getTemplatePath() { + return "preview/pdf"; + } + + @Override + public IConverter getConverter() { + return officeToPdfConverter; + } + + @Override + protected void fillSpecificModel(PreviewContext context, Model model) { + } + + @Override + public int getPriority() { + return 5; + } + +} diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/PdfPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/PdfPreviewStrategy.java index 87011b1a..63323cd2 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/PdfPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/PdfPreviewStrategy.java @@ -16,35 +16,20 @@ public class PdfPreviewStrategy extends AbstractPreviewStrategy { @Override public boolean support(FileTypeEnum fileType) { - // 支持 PDF 本身,以及需要转换为 PDF 的类型(Word/Excel/PPT) - return fileType == FileTypeEnum.PDF - || fileType == FileTypeEnum.WORD - || fileType == FileTypeEnum.EXCEL - || fileType == FileTypeEnum.PPT; + return fileType == FileTypeEnum.PDF; } @Override - protected void fillSpecificModel(PreviewContext context, Model model) { - FileTypeEnum fileType = context.getFileType(); - - Boolean needConvert = context.getNeedConvert(); - if (needConvert != null && needConvert) { - model.addAttribute("needConvert", true); - model.addAttribute("originalType", fileType.getName()); - model.addAttribute("convertStatus", "pending"); - - log.info("Office 文档需要转换 - 文件: {}, 类型: {} -> PDF", - context.getFileName(), fileType.getName()); - } else { - model.addAttribute("needConvert", false); - log.info("PDF 文档直接预览 - 文件: {}", context.getFileName()); - } + public String getTemplatePath() { + return "preview/pdf"; + } - log.info("PDF 预览策略填充完成 - 文件名: {}", context.getFileName()); + @Override + protected void fillSpecificModel(PreviewContext context, Model model) { } @Override public int getPriority() { - return 20; + return 6; } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java index 24b03e85..5dce0d6c 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/UnsupportedPreviewStrategy.java @@ -19,12 +19,15 @@ public class UnsupportedPreviewStrategy extends AbstractPreviewStrategy { return false; } + @Override + public String getTemplatePath() { + return "preview/unsupported"; + } + @Override protected void fillSpecificModel(PreviewContext context, Model model) { log.warn("不支持预览的文件类型: {}", context.getFileType().getName()); - model.addAttribute("message", "该文件类型暂不支持在线预览"); - model.addAttribute("supportDownload", true); } @Override diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/VideoPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/VideoPreviewStrategy.java index f4bcc6da..29c54732 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/VideoPreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/VideoPreviewStrategy.java @@ -19,17 +19,18 @@ public class VideoPreviewStrategy extends AbstractPreviewStrategy { return fileType == FileTypeEnum.VIDEO; } + @Override + public String getTemplatePath() { + return "preview/video"; + } + @Override protected void fillSpecificModel(PreviewContext context, Model model) { - log.info("视频预览策略填充完成 - 文件名: {}, 格式: {}, 大小: {}", - context.getFileName(), - context.getExtension(), - context.getFileSize()); } @Override public int getPriority() { - return 15; + return 7; } } diff --git a/fs-framework/fs-preview/src/main/resources/templates/preview/unsupported.html b/fs-framework/fs-preview/src/main/resources/templates/preview/unsupported.html new file mode 100644 index 00000000..e69de29b diff --git a/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java b/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java index 5707e699..5d7c37b7 100644 --- a/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java +++ b/fs-framework/fs-storage-plugin/storage-plugin-local/src/main/java/com/xddcodec/fs/storage/plugin/local/config/LocalStorageProperties.java @@ -23,7 +23,7 @@ public class LocalStorageProperties { /** * 访问基础URL */ - private String baseUrl = "http://localhost:8081/files"; + private String baseUrl = "http://localhost:8080/files"; /** * 转换为 StorageConfig 的 properties Map diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java index f83584cc..d87cfb3e 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java @@ -2,14 +2,16 @@ package com.xddcodec.fs.file.controller; import com.xddcodec.fs.file.domain.FileInfo; import com.xddcodec.fs.file.service.FileInfoService; +import com.xddcodec.fs.framework.common.enums.FileTypeEnum; import com.xddcodec.fs.framework.preview.config.FilePreviewConfig; +import com.xddcodec.fs.framework.preview.core.PreviewStrategy; +import com.xddcodec.fs.framework.preview.factory.PreviewStrategyManager; import com.xddcodec.fs.storage.facade.StorageServiceFacade; import com.xddcodec.fs.storage.plugin.core.IStorageOperationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @@ -31,93 +33,107 @@ public class FileStreamController { private final FileInfoService fileInfoService; private final StorageServiceFacade storageServiceFacade; private final FilePreviewConfig previewConfig; + private final PreviewStrategyManager strategyManager; + private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(\\d*)-(\\d*)"); @GetMapping("/preview/{fileId}") public ResponseEntity preview( @PathVariable String fileId, @RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader) { + FileInfo fileInfo = fileInfoService.getById(fileId); if (fileInfo == null) { return ResponseEntity.notFound().build(); } - // 获取存储服务 + IStorageOperationService storage = storageServiceFacade .getStorageService(fileInfo.getStoragePlatformSettingId()); - long fileSize = fileInfo.getSize(); - // 处理 Range 请求 (视频/音频拖动进度条) - if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { - return handleRangeRequest(storage, fileInfo, rangeHeader, fileSize); + FileTypeEnum fileType = FileTypeEnum.fromFileName(fileInfo.getDisplayName()); + PreviewStrategy strategy = strategyManager.getStrategy(fileType); + + if (!strategy.supportRange() || rangeHeader == null || !rangeHeader.startsWith("bytes=")) { + return handleFullRequest(storage, fileInfo, strategy); } - // 处理全量流式请求 (普通下载或图片加载) - return handleFullRequest(storage, fileInfo, fileSize); + + return handleRangeRequest(storage, fileInfo, strategy, rangeHeader); } - /** - * 处理全量流式传输 - */ private ResponseEntity handleFullRequest( - IStorageOperationService storage, FileInfo fileInfo, long fileSize) { + IStorageOperationService storage, FileInfo fileInfo, PreviewStrategy strategy) { + StreamingResponseBody stream = outputStream -> { - try (InputStream inputStream = storage.getFileStream(fileInfo.getObjectKey())) { - copyStream(inputStream, outputStream); + try (InputStream sourceStream = storage.getFileStream(fileInfo.getObjectKey()); + InputStream processedStream = strategy.processStream(sourceStream, fileInfo.getSuffix())) { + + copyStream(processedStream, outputStream); + } catch (IOException e) { - log.debug("文件流传输中断 (用户可能是取消了请求): {}", fileInfo.getDisplayName()); + log.debug("文件流传输中断: {}", fileInfo.getDisplayName()); } }; - return ResponseEntity.ok() - .headers(buildHeaders(fileInfo, fileSize, false)) - .body(stream); + + HttpHeaders headers = buildHeaders(fileInfo, strategy, fileInfo.getSize(), false); + return ResponseEntity.ok().headers(headers).body(stream); } - /** - * 处理 Range (断点续传/分片) 请求 - */ private ResponseEntity handleRangeRequest( - IStorageOperationService storage, FileInfo fileInfo, String rangeHeader, long fileSize) { + IStorageOperationService storage, FileInfo fileInfo, + PreviewStrategy strategy, String rangeHeader) { + + long fileSize = fileInfo.getSize(); long start = 0; long end = fileSize - 1; + Matcher matcher = RANGE_PATTERN.matcher(rangeHeader); if (matcher.matches()) { String startGroup = matcher.group(1); String endGroup = matcher.group(2); if (!startGroup.isEmpty()) start = Long.parseLong(startGroup); - if (!endGroup.isEmpty()) end = Long.parseLong(endGroup); + if (!endGroup.isEmpty()) end = Math.min(Long.parseLong(endGroup), fileSize - 1); } - // 修正 end 范围 - if (end >= fileSize) end = fileSize - 1; + long maxRangeSize = previewConfig.getMaxRangeSize(); + if (end - start + 1 > maxRangeSize) { + end = start + maxRangeSize - 1; + } final long finalStart = start; final long finalEnd = end; final long contentLength = finalEnd - finalStart + 1; + StreamingResponseBody stream = outputStream -> { try (InputStream inputStream = storage.getFileStream(fileInfo.getObjectKey())) { - // 跳过不需要的字节 - if (finalStart > 0) { - long skipped = inputStream.skip(finalStart); - if (skipped < finalStart) { - // 防御性代码:如果skip不到位,手动读取丢弃 - // 实际生产建议封装工具类 - } - } - // 只传输 range 范围内的字节 + skipBytes(inputStream, finalStart); copyStreamLimited(inputStream, outputStream, contentLength); } catch (IOException e) { log.debug("Range流传输中断: {}", fileInfo.getDisplayName()); } }; - HttpHeaders headers = buildHeaders(fileInfo, contentLength, true); - headers.add(HttpHeaders.CONTENT_RANGE, String.format("bytes %d-%d/%d", finalStart, finalEnd, fileSize)); - return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) - .headers(headers) - .body(stream); + + HttpHeaders headers = buildHeaders(fileInfo, strategy, contentLength, true); + headers.add(HttpHeaders.CONTENT_RANGE, + String.format("bytes %d-%d/%d", finalStart, finalEnd, fileSize)); + + return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).headers(headers).body(stream); + } + + private void skipBytes(InputStream in, long skipCount) throws IOException { + if (skipCount <= 0) return; + + long remaining = skipCount; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped == 0) { + if (in.read() == -1) throw new IOException("无法跳过指定字节数"); + remaining--; + } else { + remaining -= skipped; + } + } } - /** - * 通用流拷贝 - */ private void copyStream(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[previewConfig.getBufferSize()]; int bytesRead; @@ -127,38 +143,49 @@ public class FileStreamController { out.flush(); } - /** - * 限制长度的流拷贝 - */ private void copyStreamLimited(InputStream in, OutputStream out, long limit) throws IOException { byte[] buffer = new byte[previewConfig.getBufferSize()]; long totalRead = 0; int bytesRead; - while (totalRead < limit && (bytesRead = in.read(buffer, 0, (int) Math.min(limit - totalRead, previewConfig.getBufferSize()))) != -1) { + + while (totalRead < limit) { + int toRead = (int) Math.min(buffer.length, limit - totalRead); + bytesRead = in.read(buffer, 0, toRead); + if (bytesRead == -1) break; out.write(buffer, 0, bytesRead); totalRead += bytesRead; } out.flush(); } - private HttpHeaders buildHeaders(FileInfo file, long contentLength, boolean isRange) { + private HttpHeaders buildHeaders(FileInfo file, PreviewStrategy strategy, + long contentLength, boolean isRange) { HttpHeaders headers = new HttpHeaders(); headers.setContentLength(contentLength); - // 自动识别 Content-Type (这里简化处理,实际上应该根据后缀映射正确MIME) - String contentType = isPdf(file.getSuffix()) ? "application/pdf" : MediaType.APPLICATION_OCTET_STREAM_VALUE; - headers.setContentType(MediaType.parseMediaType(contentType)); - // inline 表示浏览器直接展示,attachment 表示下载 - headers.set(HttpHeaders.CONTENT_DISPOSITION, "inline; filename*=UTF-8''" + encodeFileName(file.getDisplayName())); - headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); + String responseExtension = strategy.getResponseExtension(file.getSuffix()); + String fileName = changeExtension(file.getDisplayName(), responseExtension); + + headers.set(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename*=UTF-8''" + encodeFileName(fileName)); + + if (strategy.supportRange()) { + headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); + headers.setCacheControl("public, max-age=604800"); + } else { + headers.set(HttpHeaders.ACCEPT_RANGES, "none"); + headers.setCacheControl("public, max-age=3600"); + } - // 缓存策略 - headers.setCacheControl("public, max-age=604800"); // 缓存7天 return headers; } - private boolean isPdf(String suffix) { - return suffix != null && "pdf".equalsIgnoreCase(suffix); + private String changeExtension(String fileName, String newExtension) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex == -1) return fileName + "." + newExtension; + String originalExtension = fileName.substring(dotIndex + 1); + if (originalExtension.equalsIgnoreCase(newExtension)) return fileName; + return fileName.substring(0, dotIndex) + "." + newExtension; } private String encodeFileName(String name) { diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java index efc87387..81f25cb5 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/preview/PreviewService.java @@ -8,81 +8,49 @@ import com.xddcodec.fs.framework.preview.core.PreviewContext; import com.xddcodec.fs.framework.preview.core.PreviewStrategy; import com.xddcodec.fs.framework.preview.factory.PreviewStrategyManager; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.ui.Model; /** * 预览服务 */ -@Slf4j @Service @RequiredArgsConstructor public class PreviewService { private final FileInfoService fileInfoService; private final PreviewStrategyManager strategyManager; private final FilePreviewConfig previewConfig; - - @Value("${server.servlet.context-path:}") - private String contextPath; public String preview(String fileId, Model model) { if (fileId == null || fileId.trim().isEmpty()) { - log.warn("预览失败: fileId 为空"); return buildErrorPage(model, "文件ID无效", "文件ID不能为空"); } - - FileInfo fileInfo = null; - try { - fileInfo = fileInfoService.getById(fileId); - } catch (Exception e) { - log.error("查询文件信息失败: fileId={}", fileId, e); - return buildErrorPage(model, "查询文件失败", "无法查询文件信息"); - } - + FileInfo fileInfo = fileInfoService.getById(fileId); if (fileInfo == null) { - log.warn("预览失败: 文件不存在, fileId={}", fileId); return buildErrorPage(model, "文件不存在", "文件不存在或已被删除"); } - if (fileInfo.getSize() != null && fileInfo.getSize() > previewConfig.getMaxFileSize()) { - log.warn("预览失败: 文件过大, fileId={}, size={}MB", - fileId, fileInfo.getSize() / 1024 / 1024); - return buildErrorPage(model, "文件过大", - String.format("文件大小为 %dMB,超过预览限制(%dMB),请下载后查看", - fileInfo.getSize() / 1024 / 1024, + if (fileInfo.getSize() > previewConfig.getMaxFileSize()) { + return buildErrorPage(model, "文件过大", + String.format("文件大小超过预览限制(%dMB)", previewConfig.getMaxFileSize() / 1024 / 1024)); } FileTypeEnum fileType = FileTypeEnum.fromFileName(fileInfo.getDisplayName()); - if (!fileType.isPreviewable()) { - log.warn("预览失败: 文件类型不支持预览, fileName={}, fileType={}", - fileInfo.getDisplayName(), fileType.getName()); - return buildErrorPage(model, "不支持的文件类型", - "该文件类型暂不支持在线预览,请下载后查看"); + PreviewStrategy strategy = strategyManager.getStrategy(fileType); + if (strategy == null) { + return buildErrorPage(model, "不支持的文件类型", "该文件类型暂不支持在线预览"); } - try { - String streamUrl = buildUrl("/api/file/stream/preview/", fileId); - PreviewContext context = PreviewContext.builder() - .fileName(fileInfo.getDisplayName()) - .streamUrl(streamUrl) - .fileSize(fileInfo.getSize()) - .extension(fileInfo.getSuffix()) - .fileType(fileType) - .needConvert(fileType.isNeedConvert()) - .build(); - - PreviewStrategy strategy = strategyManager.getStrategy(fileType); - strategy.fillModel(context, model); - - log.info("预览成功: fileName={}, fileType={}", fileInfo.getDisplayName(), fileType.getName()); - return fileType.getPreviewTemplate(); - } catch (Exception e) { - log.error("构建预览上下文失败: fileId={}, fileName={}", fileId, fileInfo.getDisplayName(), e); - return buildErrorPage(model, "预览失败", "无法加载文件预览"); - } + PreviewContext context = PreviewContext.builder() + .fileName(fileInfo.getDisplayName()) + .streamUrl(previewConfig.getStreamApi() + "/" + fileId) + .fileSize(fileInfo.getSize()) + .extension(fileInfo.getSuffix()) + .fileType(fileType) + .build(); + strategy.fillModel(context, model); + return strategy.getTemplatePath(); } /** @@ -93,8 +61,4 @@ public class PreviewService { model.addAttribute("errorDetail", errorDetail); return "preview/error"; } - - private String buildUrl(String path, String fileId) { - return (contextPath.isEmpty() ? "" : contextPath) + path + fileId; - } } -- Gitee From 70520bc64499827196a1b3fd415b094618eaae3e Mon Sep 17 00:00:00 2001 From: Freedom <459102951@qq.com> Date: Fri, 21 Nov 2025 08:29:30 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f30bb2a9..d0e6cad0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ --- + +- 当前为预览版本,部分功能还未实现,如有问题,请提Issues谢谢! + ## 源码链接: Gitee:https://gitee.com/xddcode/free-fs -- Gitee From 284da36fa7a807442718c4f7038b6ba6bc7f90a9 Mon Sep 17 00:00:00 2001 From: Freedom <459102951@qq.com> Date: Fri, 21 Nov 2025 09:45:41 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96excel=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=92=8Cword=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../framework/common/enums/FileTypeEnum.java | 2 +- .../converter/impl/OfficeToPdfConverter.java | 3 +- .../office/JodConverterConfiguration.java | 26 +- .../strategy/impl/ExcelPreviewStrategy.java | 36 ++ .../strategy/impl/OfficePreviewStrategy.java | 1 - .../resources/templates/preview/audio.html | 2 +- .../resources/templates/preview/excel.html | 335 ++++++++++++++++++ .../main/resources/templates/preview/pdf.html | 2 +- .../resources/templates/preview/video.html | 2 +- fs-framework/fs-security/pom.xml | 8 +- .../file/controller/FileStreamController.java | 74 ++-- 11 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ExcelPreviewStrategy.java create mode 100644 fs-framework/fs-preview/src/main/resources/templates/preview/excel.html diff --git a/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java b/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java index 14140488..3a022d66 100644 --- a/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java +++ b/fs-framework/fs-common-core/src/main/java/com/xddcodec/fs/framework/common/enums/FileTypeEnum.java @@ -43,7 +43,7 @@ public enum FileTypeEnum { )), EXCEL("excel", "Excel表格", FileCategory.DOCUMENT, Arrays.asList( - "xls", "xlsx", "XLS", "XLSX" + "xls", "xlsx", "XLS", "XLSX", "csv", "CSV" )), PPT("ppt", "PPT演示", FileCategory.DOCUMENT, Arrays.asList( diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java index da917221..330dc63b 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/converter/impl/OfficeToPdfConverter.java @@ -8,7 +8,6 @@ import org.jodconverter.core.DocumentConverter; import org.jodconverter.core.office.OfficeException; import org.jodconverter.core.office.OfficeManager; import org.jodconverter.local.LocalConverter; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Component; import java.io.ByteArrayInputStream; @@ -53,7 +52,7 @@ public class OfficeToPdfConverter implements IConverter { // 读取结果 byte[] pdfData = Files.readAllBytes(tempOutputFile); - log.debug("Office文件转换成功: {} -> PDF, size={}KB", + log.info("Office文件转换成功: {} -> PDF, size={}KB", sourceExtension, pdfData.length / 1024); return new ByteArrayInputStream(pdfData); diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java index 96589d53..9b1155bc 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/office/JodConverterConfiguration.java @@ -1,5 +1,6 @@ package com.xddcodec.fs.framework.preview.office; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jodconverter.core.office.OfficeManager; @@ -18,6 +19,8 @@ public class JodConverterConfiguration { private final OfficeToPdfConfig config; + private OfficeManager officeManager; + @Bean public OfficeManager officeManager() { // 自动创建工作目录 @@ -31,22 +34,35 @@ public class JodConverterConfiguration { } LocalOfficeManager.Builder builder = LocalOfficeManager.builder() .officeHome(config.getOfficeHome()) -// .poolSize(config.getPoolSize()) .taskExecutionTimeout(config.getTaskExecutionTimeout()) .taskQueueTimeout(config.getTaskQueueTimeout()) .maxTasksPerProcess(config.getMaxTasksPerProcess()) .workingDir(new File(config.getCachePath())); - OfficeManager manager = builder.build(); + officeManager = builder.build(); try { - manager.start(); + officeManager.start(); log.info("LibreOffice 进程池启动成功: home={}, poolSize={}", config.getOfficeHome(), config.getPoolSize()); } catch (Exception e) { log.error("LibreOffice 进程池启动失败", e); - throw new IllegalStateException("无法启动 LibreOffice,请检查安装路径: " + config.getOfficeHome(), e); } - return manager; + return officeManager; + } + + /** + * 确保项目关闭时,LibreOffice 也能关闭 + */ + @PreDestroy + public void destroy() { + if (officeManager != null && officeManager.isRunning()) { + log.info("正在关闭 LibreOffice 进程..."); + try { + officeManager.stop(); + } catch (Exception e) { + log.error("关闭 LibreOffice 异常", e); + } + } } } diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ExcelPreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ExcelPreviewStrategy.java new file mode 100644 index 00000000..098e6a05 --- /dev/null +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/ExcelPreviewStrategy.java @@ -0,0 +1,36 @@ +package com.xddcodec.fs.framework.preview.strategy.impl; + +import com.xddcodec.fs.framework.common.enums.FileTypeEnum; +import com.xddcodec.fs.framework.preview.core.PreviewContext; +import com.xddcodec.fs.framework.preview.strategy.AbstractPreviewStrategy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.ui.Model; + +/** + * 音频预览策略 + */ +@Slf4j +@Component +public class ExcelPreviewStrategy extends AbstractPreviewStrategy { + + @Override + public boolean support(FileTypeEnum fileType) { + return fileType == FileTypeEnum.EXCEL; + } + + @Override + public String getTemplatePath() { + return "preview/excel"; + } + + @Override + protected void fillSpecificModel(PreviewContext context, Model model) { + } + + @Override + public int getPriority() { + return 1; + } +} + diff --git a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java index 02e836f9..c4b94240 100644 --- a/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java +++ b/fs-framework/fs-preview/src/main/java/com/xddcodec/fs/framework/preview/strategy/impl/OfficePreviewStrategy.java @@ -21,7 +21,6 @@ public class OfficePreviewStrategy extends AbstractPreviewStrategy { @Override public boolean support(FileTypeEnum fileType) { return fileType == FileTypeEnum.WORD || - fileType == FileTypeEnum.EXCEL || fileType == FileTypeEnum.PPT; } diff --git a/fs-framework/fs-preview/src/main/resources/templates/preview/audio.html b/fs-framework/fs-preview/src/main/resources/templates/preview/audio.html index c7674039..995389e6 100644 --- a/fs-framework/fs-preview/src/main/resources/templates/preview/audio.html +++ b/fs-framework/fs-preview/src/main/resources/templates/preview/audio.html @@ -181,7 +181,7 @@
-
正在加载音频...
+
正在加载资源...
diff --git a/fs-framework/fs-preview/src/main/resources/templates/preview/excel.html b/fs-framework/fs-preview/src/main/resources/templates/preview/excel.html new file mode 100644 index 00000000..d80e92c2 --- /dev/null +++ b/fs-framework/fs-preview/src/main/resources/templates/preview/excel.html @@ -0,0 +1,335 @@ + + + + + + + 表格预览 + + + + + + + + + + +
+ Preview +
+ + + + +
+
+ +
+
+
+
正在加载资源...
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/fs-framework/fs-preview/src/main/resources/templates/preview/pdf.html b/fs-framework/fs-preview/src/main/resources/templates/preview/pdf.html index 6fce2fe0..5ec10f8f 100644 --- a/fs-framework/fs-preview/src/main/resources/templates/preview/pdf.html +++ b/fs-framework/fs-preview/src/main/resources/templates/preview/pdf.html @@ -132,7 +132,7 @@
-
正在加载 PDF...
+
正在加载资源...
diff --git a/fs-framework/fs-preview/src/main/resources/templates/preview/video.html b/fs-framework/fs-preview/src/main/resources/templates/preview/video.html index a90705a5..f83b0193 100644 --- a/fs-framework/fs-preview/src/main/resources/templates/preview/video.html +++ b/fs-framework/fs-preview/src/main/resources/templates/preview/video.html @@ -150,7 +150,7 @@
-
正在加载视频...
+
正在加载资源...
diff --git a/fs-framework/fs-security/pom.xml b/fs-framework/fs-security/pom.xml index 49ab1eef..7aed454b 100644 --- a/fs-framework/fs-security/pom.xml +++ b/fs-framework/fs-security/pom.xml @@ -26,9 +26,9 @@ cn.dev33 sa-token-jwt - - cn.dev33 - sa-token-redis-template - + + + + diff --git a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java index d87cfb3e..a946f027 100644 --- a/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java +++ b/fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @@ -53,6 +54,10 @@ public class FileStreamController { FileTypeEnum fileType = FileTypeEnum.fromFileName(fileInfo.getDisplayName()); PreviewStrategy strategy = strategyManager.getStrategy(fileType); + log.info("文件: {}, 类型: {}, 匹配策略: {}", fileInfo.getDisplayName(), fileType, strategy.getClass().getSimpleName()); + + // 修复逻辑:如果策略不支持Range(说明是转换流,如Docx转PDF),则强制走FullRequest + // 即使前端传了Range头也不处理,防止截断 if (!strategy.supportRange() || rangeHeader == null || !rangeHeader.startsWith("bytes=")) { return handleFullRequest(storage, fileInfo, strategy); } @@ -74,6 +79,7 @@ public class FileStreamController { } }; + // 传入 fileInfo.getSize() 仅作为参考,buildHeaders 内部决定是否使用 HttpHeaders headers = buildHeaders(fileInfo, strategy, fileInfo.getSize(), false); return ResponseEntity.ok().headers(headers).body(stream); } @@ -119,6 +125,52 @@ public class FileStreamController { return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).headers(headers).body(stream); } + /** + * 构建响应头(核心修复位置) + */ + private HttpHeaders buildHeaders(FileInfo file, PreviewStrategy strategy, + long visibleLength, boolean isRange) { + HttpHeaders headers = new HttpHeaders(); + + String responseExtension = strategy.getResponseExtension(file.getSuffix()); + String fileName = changeExtension(file.getDisplayName(), responseExtension); + + // === 核心修复开始 === + + // 1. 设置 Content-Type + // 如果是PDF预览,强制设置 application/pdf,否则有些浏览器会下载而不是预览 + if ("pdf".equalsIgnoreCase(responseExtension)) { + headers.setContentType(MediaType.APPLICATION_PDF); + } else { + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + } + + // 2. 智能设置 Content-Length + // 如果该策略需要转换(needConvert=true, 如Word转PDF),因为文件大小变了, + // 所以不能使用数据库里的原始文件大小。不设置Length,走Chunked传输。 + // 如果是Range请求,说明是流片段,长度是确定的,必须设置。 + if (isRange || !strategy.needConvert()) { + headers.setContentLength(visibleLength); + } + // 这里的 Else 就是核心:needConvert=true 且不是Range请求 -> 不设置 Content-Length + + // === 核心修复结束 === + + headers.set(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename*=UTF-8''" + encodeFileName(fileName)); + + if (strategy.supportRange()) { + headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); + headers.setCacheControl("public, max-age=604800"); + } else { + headers.set(HttpHeaders.ACCEPT_RANGES, "none"); + // 转换后的流尽量不长缓存,或者根据业务需求调整 + headers.setCacheControl("no-cache"); + } + + return headers; + } + private void skipBytes(InputStream in, long skipCount) throws IOException { if (skipCount <= 0) return; @@ -158,28 +210,6 @@ public class FileStreamController { out.flush(); } - private HttpHeaders buildHeaders(FileInfo file, PreviewStrategy strategy, - long contentLength, boolean isRange) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentLength(contentLength); - - String responseExtension = strategy.getResponseExtension(file.getSuffix()); - String fileName = changeExtension(file.getDisplayName(), responseExtension); - - headers.set(HttpHeaders.CONTENT_DISPOSITION, - "inline; filename*=UTF-8''" + encodeFileName(fileName)); - - if (strategy.supportRange()) { - headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); - headers.setCacheControl("public, max-age=604800"); - } else { - headers.set(HttpHeaders.ACCEPT_RANGES, "none"); - headers.setCacheControl("public, max-age=3600"); - } - - return headers; - } - private String changeExtension(String fileName, String newExtension) { int dotIndex = fileName.lastIndexOf('.'); if (dotIndex == -1) return fileName + "." + newExtension; -- Gitee