From 2425bea9b6a47809a796989e57c5733668e265ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E9=9B=84?= Date: Thu, 15 Jan 2026 15:43:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E6=96=B9=E6=B3=95=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E8=BD=AC=E6=8D=A2=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 - server/pom.xml | 40 +- server/src/main/config/application.properties | 287 +++----- .../main/config/application详细.properties | 544 +++++++++++++++ .../cn/keking/config/ConfigConstants.java | 588 +++++++++++++---- .../keking/config/ConfigRefreshComponent.java | 135 +++- .../java/cn/keking/service/FilePreview.java | 1 + .../cn/keking/service/Mediatomp4Service.java | 621 ++++++++++++++++++ .../cn/keking/service/OfficeToPdfService.java | 42 +- .../cn/keking/service/PdfToJpgService.java | 581 ++++++++-------- .../cn/keking/service/TifToPdfService.java | 472 +++++++++++++ .../java/cn/keking/service/TifToService.java | 426 ------------ .../service/impl/CadFilePreviewImpl.java | 123 +++- .../service/impl/MediaFilePreviewImpl.java | 386 +++++------ .../service/impl/TiffFilePreviewImpl.java | 11 +- .../java/cn/keking/utils/DownloadUtils.java | 61 +- .../utils/FileConvertStatusManager.java | 257 ++++++++ .../main/java/cn/keking/utils/SslUtils.java | 71 +- .../controller/OnlinePreviewController.java | 48 +- server/src/main/resources/web/waiting.ftl | 111 ++++ 20 files changed, 3431 insertions(+), 1376 deletions(-) create mode 100644 server/src/main/config/application详细.properties create mode 100644 server/src/main/java/cn/keking/service/Mediatomp4Service.java create mode 100644 server/src/main/java/cn/keking/service/TifToPdfService.java delete mode 100644 server/src/main/java/cn/keking/service/TifToService.java create mode 100644 server/src/main/java/cn/keking/utils/FileConvertStatusManager.java create mode 100644 server/src/main/resources/web/waiting.ftl diff --git a/pom.xml b/pom.xml index 1841c289..f7b4c4df 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,6 @@ 3.0.6 - 5.5.13.4 1.4.0 @@ -51,7 +50,6 @@ 1.4.2 - 5.6 4.5.16 3.12.0 diff --git a/server/pom.xml b/server/pom.xml index 29f4c7b3..c28ff099 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -23,15 +23,20 @@ - - - aspose-maven-repository - https://repository.aspose.com/repo - - false - - - + + + + + aspose-maven-repository + https://repository.aspose.com/repo + + true + + + false + + + @@ -115,11 +120,6 @@ pdfbox-tools ${pdfbox.version} - - com.itextpdf - itextpdf - ${itextpdf.version} - @@ -303,18 +303,6 @@ spring-boot-starter-test test - - commons-httpclient - commons-httpclient - ${httpclient.version} - test - - - commons-logging - commons-logging - - - diff --git a/server/src/main/config/application.properties b/server/src/main/config/application.properties index a4161f48..1958b96c 100644 --- a/server/src/main/config/application.properties +++ b/server/src/main/config/application.properties @@ -1,18 +1,12 @@ -#######################################不可动态配置,需要重启生效####################################### +####################################### 一、服务器基础配置(不可动态配置,需重启生效)####################################### server.port = ${KK_SERVER_PORT:8012} -server.servlet.context-path= ${KK_CONTEXT_PATH:/} +server.servlet.context-path = ${KK_CONTEXT_PATH:/} server.servlet.encoding.charset = utf-8 -#启用GZIP压缩功能 server.compression.enabled = true -#允许压缩的响应缓冲区最小字节数,默认2048 server.compression.min-response-size = 2048 -#压缩格式 -server.compression.mime-types=application/javascript,text/css,application/json,application/xml,text/html,text/xml,text/plain,font/woff,application/font-woff,font/eot,image/svg+xml,image/x-icon -# 文件上传限制前端 -spring.servlet.multipart.max-file-size=500MB -#文件上传限制 -spring.servlet.multipart.max-request-size=500MB -## Freemarker 配置 +server.compression.mime-types = application/javascript,text/css,application/json,application/xml,text/html,text/xml,text/plain,font/woff,application/font-woff,font/eot,image/svg+xml,image/x-icon +spring.servlet.multipart.max-file-size = 500MB +spring.servlet.multipart.max-request-size = 500MB spring.freemarker.template-loader-path = classpath:/web/ spring.freemarker.cache = false spring.freemarker.charset = UTF-8 @@ -22,226 +16,119 @@ spring.freemarker.expose-request-attributes = true spring.freemarker.expose-session-attributes = true spring.freemarker.request-context-attribute = request spring.freemarker.suffix = .ftl -# Spring Boot Actuator 健康检查配置 -# 开启健康检查端点 -management.endpoints.web.exposure.include=health,info,metrics -# 显示详细的健康检查信息(生产环境建议设置为when-authorized) -management.endpoint.health.show-details=always -# 启用健康检查组件 -management.health.defaults.enabled=true +management.endpoints.web.exposure.include = health,info,metrics +management.endpoint.health.show-details = always +management.health.defaults.enabled = true - -# office设置 -#openoffice或LibreOffice home路径 -#office.home = C:\\Program Files (x86)\\OpenOffice 4 +####################################### 二、Office文档处理配置(部分支持动态配置)####################################### office.home = ${KK_OFFICE_HOME:default} -## office转换服务的端口,默认开启两个进程 office.plugin.server.ports = 2001,2002 -## office 转换服务 task 超时时间,默认五分钟 office.plugin.task.timeout = 5m -#此属性设置office进程在重新启动之前可以执行的最大任务数。0表示无限数量的任务(永远不会重新启动) office.plugin.task.maxtasksperprocess = 200 -#此属性设置处理任务所允许的最长时间。如果任务的处理时间长于此超时,则此任务将中止,并处理下一个任务。 office.plugin.task.taskexecutiontimeout = 5m -#生成限制 默认不限制 使用方法 (1-5) office.pagerange = ${KK_OFFICE_PAGERANGE:false} -#生成水印 默认不启用 使用方法 (kkFileView) -office.watermark = ${KK_OFFICE_WATERMARK:false} -#OFFICE JPEG图片压缩 +office.watermark = ${KK_OFFICE_WATERMARK:false} office.quality = ${KK_OFFICE_QUALITY:80} -#图像分辨率限制 office.maximageresolution = ${KK_OFFICE_MAXIMAGERESOLUTION:150} -#导出书签 office.exportbookmarks = ${KK_OFFICE_EXPORTBOOKMARKS:true} -#批注作为PDF的注释 office.exportnotes = ${KK_OFFICE_EXPORTNOTES:true} -#加密文档 生成的PDF文档 添加密码(密码为加密文档的密码) office.documentopenpasswords = ${KK_OFFICE_DOCUMENTOPENPASSWORD:true} -#xlsx格式前端解析 office.type.web = ${KK_OFFICE_TYPE_WEB:web} - -# 其他核心设置 -#预览生成资源路径(默认为打包根路径下的file目录下) -#file.dir = D:\\kkFileview\\ +####################################### 三、文件存储与缓存配置 ####################################### file.dir = ${KK_FILE_DIR:default} -#允许预览的本地文件夹 默认不允许任何本地文件被预览 -#WINDOWS参考 local.preview.dir = \D:\\kkFileview\\1\\1.txt (注意前面必须添加反斜杠) -#LINUX参考 local.preview.dir = /opt/1.txt (注意前面必须是正斜杠) -#使用方法 windows file://d:/1/1.txt linux file:/opt/1/1.txt -#file 协议参考:https://datatracker.ietf.org/doc/html/rfc8089 local.preview.dir = ${KK_LOCAL_PREVIEW_DIR:default} -#是否启用缓存 cache.enabled = ${KK_CACHE_ENABLED:true} -#缓存实现类型,不配默认为内嵌RocksDB(type = default)实现,可配置为redis(type = redis)实现(需要配置spring.redisson.address等参数)和 JDK 内置对象实现(type = jdk), -cache.type = ${KK_CACHE_TYPE:jdk} -#redis 连接,只有当cache.type = redis时才有用 - # single: 单机模式 - # address: redis://localhost:6379 - # cluster: 集群模式 - # 每个节点逗号分隔,同时每个节点前必须以redis://开头。 - # address: redis://localhost:6379,redis://localhost:6378,... - # sentinel: 哨兵模式 - # 每个节点逗号分隔,同时每个节点前必须以redis://开头。 - # address: redis://localhost:6379,redis://localhost:6378,... - # master-slave: 主从模式 - # 每个节点逗号分隔,第一个为主节点,其余为从节点。同时每个节点前必须以redis://开头。 - # address: redis://localhost:6379,redis://localhost:6378,... +cache.type = ${KK_CACHE_TYPE:jdk} spring.redisson.mode = single spring.redisson.address = ${KK_SPRING_REDISSON_ADDRESS:redis://127.0.0.1:6379} spring.redisson.password = ${KK_SPRING_REDISSON_PASSWORD:} -#redis 设置库 spring.redisson.database = ${KK_SPRING_REDISSON_DATABASE:0} -#哨兵模式 打开 masterName设置 -#spring.redisson.masterName = kkfile -#缓存是否自动清理 true 为开启,注释掉或其他值都为关闭 cache.clean.enabled = ${KK_CACHE_CLEAN_ENABLED:true} -#缓存自动清理时间,cache.clean.enabled = true时才有用,cron表达式,基于Quartz cron cache.clean.cron = ${KK_CACHE_CLEAN_CRON:0 0 3 * * ?} -#######################################可在运行时动态配置####################################### -#提供预览服务的地址,默认从请求url读,如果使用nginx等反向代理,需要手动设置 -#base.url = https://file.keking.cn + +####################################### 四、安全与访问控制配置(支持动态配置)####################################### base.url = ${KK_BASE_URL:default} - -# ========== 安全配置(重要)========== -# 信任站点白名单配置,多个用','隔开 -# ⚠️ 安全提示:为防止SSRF攻击,强烈建议配置信任主机白名单 -# ⚠️ 如果不配置,系统将默认拒绝所有外部文件预览请求 -# -# 配置示例: -# trust.host = kkview.cn,yourdomain.com,cdn.example.com -# -# 如果需要允许所有域名(不推荐,仅用于测试环境),请设置为: -# trust.host = * -# -# 当前配置: trust.host = * - -# 不信任站点黑名单配置,多个用','隔开 -# 黑名单优先级高于白名单,设置后将禁止预览来自这些站点的文件 -# 建议配置:禁止访问内网地址和本地地址 -# not.trust.host = localhost,127.0.0.1,0.0.0.0,192.168.*,10.*,172.16.* -not.trust.host= ${KK_NOT_TRUST_HOST:default} -#文本类型,默认如下,可自定义添加 -simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd} - - -#FTP模块设置 -#预览源为FTP时 FTP用户名,可在ftp url后面加参数ftp.username=ftpuser指定,不指定默认用配置的 -ftp.username = ${KK_FTP_USERNAME:ftpuser} -#预览源为FTP时 FTP密码,可在ftp url后面加参数ftp.password=123456指定,不指定默认用配置的 -ftp.password = ${KK_FTP_PASSWORD:123456} -#预览源为FTP时, FTP连接默认ControlEncoding(根据FTP服务器操作系统选择,Linux一般为UTF-8,Windows一般为GBK),可在ftp url后面加参数ftp.control.encoding=UTF-8指定,不指定默认用配置的 -ftp.control.encoding = ${KK_FTP_CONTROL_ENCODING:UTF-8} - -#视频设置 -#多媒体类型,默认如下,可自定义添加 -media = ${KK_MEDIA:mp3,wav,mp4,flv,mpd,m3u8,ts,mpeg,m4a} -#是否开启多媒体类型转视频格式转换,目前可转换视频格式有:avi,mov,wmv,3gp,rm -#请谨慎开启此功能,建议异步调用添加到处理队列,并且增加任务队列处理线程,防止视频转换占用完线程资源,转换比较耗费时间,并且控制了只能串行处理转换任务 -media.convert.disable = ${KK_MEDIA_CONVERT_DISABLE:true} -#支持转换的视频类型 -convertMedias = ${KK_CONVERTMEDIAS:avi,mov,wmv,mkv,3gp,rm,mpeg} - - - -#PDF预览模块设置 -#配置PDF文件生成图片的像素大小,dpi 越高,图片质量越清晰,同时也会消耗更多的计算资源。 -pdf2jpg.dpi = ${KK_PDF2JPG_DPI:144} -#PDF转换超时设置 (低于50页) 温馨提示这里数字仅供参考 -pdf.timeout =${KK_pdf_TIMEOUT:90} -#PDF转换超时设置 (高于50小于200页) -pdf.timeout80 =${KK_PDF_TIMEOUT80:180} -#PDF转换超时设置 (大于200页) -pdf.timeout200 =${KK_PDF_TIMEOUT200:300} -#PDF转换线程设置 -pdf.thread =${KK_PDF_THREAD:5} -#是否禁止演示模式 -pdf.presentationMode.disable = ${KK_PDF_PRESENTATION_MODE_DISABLE:true} -#是否禁止打开文件 -pdf.openFile.disable = ${KK_PDF_OPEN_FILE_DISABLE:true} -#是否禁止打印转换生成的pdf文件 -pdf.print.disable = ${KK_PDF_PRINT_DISABLE:true} -#是否禁止下载转换生成的pdf文件 -pdf.download.disable = ${KK_PDF_DOWNLOAD_DISABLE:true} -#是否禁止bookmark -pdf.bookmark.disable = ${KK_PDF_BOOKMARK_DISABLE:true} -#是否禁止签名 -pdf.disable.editing = ${KK_PDF_DISABLE_EDITING:false} -#office类型文档(word ppt)样式,默认为图片(image),可配置为pdf(预览时也有按钮切换) -office.preview.type = ${KK_OFFICE_PREVIEW_TYPE:image} -#是否关闭office预览切换开关,默认为false,可配置为true关闭 -office.preview.switch.disabled = ${KK_OFFICE_PREVIEW_SWITCH_DISABLED:false} - - -#水印内容 -#例:watermark.txt = ${WATERMARK_TXT:凯京科技内部文件,严禁外泄} -#如需取消水印,内容设置为空即可,例:watermark.txt = ${WATERMARK_TXT:} -watermark.txt = ${WATERMARK_TXT:} -#水印x轴间隔 -watermark.x.space = ${WATERMARK_X_SPACE:10} -#水印y轴间隔 -watermark.y.space = ${WATERMARK_Y_SPACE:10} -#水印字体 -watermark.font = ${WATERMARK_FONT:微软雅黑} -#水印字体大小 -watermark.fontsize = ${WATERMARK_FONTSIZE:18px} -#水印字体颜色 -watermark.color = ${WATERMARK_COLOR:black} -#水印透明度,要求设置在大于等于0.005,小于1 -watermark.alpha = ${WATERMARK_ALPHA:0.2} -#水印宽度 -watermark.width = ${WATERMARK_WIDTH:180} -#水印高度 -watermark.height = ${WATERMARK_HEIGHT:80} -#水印倾斜度数,要求设置在大于等于0,小于90 -watermark.angle = ${WATERMARK_ANGLE:10} - -#首页功能设置 -#是否禁用首页文件上传 -file.upload.disable = false -# 备案信息,默认为空 -beian = ${KK_BEIAN:default} -#禁止上传类型 +not.trust.host = ${KK_NOT_TRUST_HOST:default} prohibit = ${KK_PROHIBIT:exe,dll,dat} -#启用验证码删除文件 默认关闭 -delete.captcha= ${KK_DELETE_CAPTCHA:false} -#删除密码 -delete.password = ${KK_DELETE_PASSWORD:123456} -#删除 转换后OFFICE、CAD、TIFF、压缩包源文件 默认开启 节约磁盘空间 -delete.source.file = ${KK_DELETE_SOURCE_FILE:true} -#首页初始化加载第一页 -home.pagenumber = ${DEFAULT_HOME_PAGENUMBER:1} -#首页是否分页 -home.pagination = ${DEFAULT_HOME_PAGINATION:true} -#首页初始化单页记录数 -home.pagesize = ${DEFAULT_HOME_PAGSIZE:15} -#首页显示查询框 -home.search = ${DEFAULT_HOME_SEARCH:true} -#Tif类型设置 -#Tif类型图片浏览模式:tif(利用前端js插件浏览);jpg(转换为jpg后前端显示);pdf(转换为pdf后显示,便于打印) +####################################### 五、文件格式与预览配置(支持动态配置)####################################### +simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd} +media = ${KK_MEDIA:mp3,wav,mp4,flv,mpd,m3u8,ts,mpeg,m4a} +convertMedias = ${KK_CONVERTMEDIAS:avi,mov,wmv,mkv,3gp,rm,mpeg} tif.preview.type = ${KK_TIF_PREVIEW_TYPE:tif} - -#Cad类型设置 -#Cad类型图片浏览模式:tif(利用前端js插件浏览);svg(转换为svg显示);pdf(转换为pdf后显示,便于打印) cad.preview.type = ${KK_CAD_PREVIEW_TYPE:svg} -#Cad转换超时设置 -cad.timeout =${KK_CAD_TIMEOUT:90} -#Cad转换线程设置 -cad.thread =${KK_CAD_THREAD:5} +office.preview.type = ${KK_OFFICE_PREVIEW_TYPE:image} +office.preview.switch.disabled = ${KK_OFFICE_PREVIEW_SWITCH_DISABLED:false} +pdf.presentationMode.disable = ${KK_PDF_PRESENTATION_MODE_DISABLE:true} +pdf.openFile.disable = ${KK_PDF_OPEN_FILE_DISABLE:true} +pdf.print.disable = ${KK_PDF_PRINT_DISABLE:true} +pdf.download.disable = ${KK_PDF_DOWNLOAD_DISABLE:true} +pdf.bookmark.disable = ${KK_PDF_BOOKMARK_DISABLE:true} +pdf.disable.editing = ${KK_PDF_DISABLE_EDITING:false} +watermark.txt = ${WATERMARK_TXT:} +watermark.x.space = ${WATERMARK_X_SPACE:10} +watermark.y.space = ${WATERMARK_Y_SPACE:10} +watermark.font = ${WATERMARK_FONT:微软雅黑} +watermark.fontsize = ${WATERMARK_FONTSIZE:18px} +watermark.color = ${WATERMARK_COLOR:black} +watermark.alpha = ${WATERMARK_ALPHA:0.2} +watermark.width = ${WATERMARK_WIDTH:180} +watermark.height = ${WATERMARK_HEIGHT:80} +watermark.angle = ${WATERMARK_ANGLE:10} +cad.watermark = true -####################################### 权限配置 ####################################### -kk.Picturespreview=true -kk.Getcorsfile=true -kk.addTask=true -kk.Key=false -# 启用AES 接入方法 ase秘钥 必须16位 (接入秘钥必须相同) false (为不启用) -ase.key= 1234567890123456 +####################################### 六、性能与资源管理配置(支持动态配置)####################################### +pdf.max.threads = 10 +cad.thread = ${KK_CAD_THREAD:5} +tif.thread = 5 +media.timeout.enabled = true +media.small.file.timeout = 30 +media.medium.file.timeout = 60 +media.large.file.timeout = 180 +media.xl.file.timeout = 300 +media.xxl.file.timeout = 600 +media.xxxl.file.timeout = 1200 +pdf.timeout.small = 90 +pdf.timeout.medium = 180 +pdf.timeout.large = 300 +pdf.timeout.xlarge = 600 +cad.timeout = ${KK_CAD_TIMEOUT:90} +tif.timeout = 90 +media.convert.max.size = 300 +media.convert.disable = ${KK_MEDIA_CONVERT_DISABLE:true} +pdf.dpi.enabled = true +pdf2jpg.dpi = ${KK_PDF2JPG_DPI:144} +pdf.dpi.small = 150 +pdf.dpi.medium = 120 +pdf.dpi.large = 96 +pdf.dpi.xlarge = 72 +pdf.dpi.xxlarge = 72 -#basic.name=192.168.0.1:name:123456,192.168.0.2:name:123456,www.xxx.com:admin:pass (域名+用户名+密码 多个接入用,分割) -basic.name=10.99.1.2:aaa:bbb +####################################### 七、FTP文件访问配置(支持动态配置)####################################### +ftp.username = false -useragent=99999 +####################################### 八、首页与文件管理配置(支持动态配置)####################################### +file.upload.disable = false +beian = ${KK_BEIAN:default} +home.pagenumber = ${DEFAULT_HOME_PAGENUMBER:1} +home.pagination = ${DEFAULT_HOME_PAGINATION:true} +home.pagesize = ${DEFAULT_HOME_PAGSIZE:15} +home.search = ${DEFAULT_HOME_SEARCH:true} +delete.captcha = ${KK_DELETE_CAPTCHA:false} +delete.password = ${KK_DELETE_PASSWORD:123456} +delete.source.file = ${KK_DELETE_SOURCE_FILE:true} +####################################### 九、权限与认证配置(支持动态配置)####################################### +kk.Picturespreview = true +kk.Getcorsfile = true +kk.addTask = true +kk.Key = false +ase.key = 1234567890123456 +basic.name = 10.99.1.2:aaa:bbb +useragent = 99999 +####################################### 十、高级功能与兼容性配置(支持动态配置)####################################### +kk.ignore.ssl = true +kk.enable.redirect = true \ No newline at end of file diff --git a/server/src/main/config/application详细.properties b/server/src/main/config/application详细.properties new file mode 100644 index 00000000..e655f7de --- /dev/null +++ b/server/src/main/config/application详细.properties @@ -0,0 +1,544 @@ +####################################### 一、服务器基础配置(不可动态配置,需重启生效)####################################### + +####################### 1.1 服务器网络配置 ####################### +# 服务器端口号,默认8012,可通过环境变量 KK_SERVER_PORT 覆盖 +# 修改端口后需重启服务生效 +server.port = ${KK_SERVER_PORT:8012} + +# 服务器上下文路径,默认为根路径,可通过环境变量 KK_CONTEXT_PATH 覆盖 +# 示例:设置为 /preview 后,访问地址为 http://localhost:8012/preview +server.servlet.context-path = ${KK_CONTEXT_PATH:/} + +# 服务器请求和响应编码字符集,设置为UTF-8以支持中文和国际化 +server.servlet.encoding.charset = utf-8 + +####################### 1.2 服务器性能优化配置 ####################### +# 启用GZIP压缩功能,减少网络传输数据量,提升页面加载速度 +# 建议在生产环境中开启以节省带宽 +server.compression.enabled = true + +# 允许压缩的响应缓冲区最小字节数,默认为2048字节 +# 小于此值的响应不压缩,避免小文件压缩反而增加开销 +server.compression.min-response-size = 2048 + +# 支持GZIP压缩的MIME类型列表,包括常见的前端资源格式 +# 可根据需要添加或删除特定的MIME类型 +server.compression.mime-types = application/javascript,text/css,application/json,application/xml,text/html,text/xml,text/plain,font/woff,application/font-woff,font/eot,image/svg+xml,image/x-icon + +####################### 1.3 文件上传限制配置 ####################### +# 单个文件上传最大限制,默认为500MB +# 根据业务需求和服务器内存大小调整 +spring.servlet.multipart.max-file-size = 500MB + +# 整个请求体(含多个文件)最大限制,默认为500MB +# 设置时应大于等于 max-file-size +spring.servlet.multipart.max-request-size = 500MB + +####################### 1.4 FreeMarker模板引擎配置 ####################### +# FreeMarker模板加载路径,默认为classpath下的web目录 +# 模板文件应放置在此目录下 +spring.freemarker.template-loader-path = classpath:/web/ + +# 是否启用模板缓存,开发环境建议设置为false,生产环境建议设置为true +# false: 每次请求都重新加载模板,便于调试 +# true: 缓存模板,提升性能 +spring.freemarker.cache = false + +# 模板文件编码,设置为UTF-8以支持中文和国际化字符 +spring.freemarker.charset = UTF-8 + +# 检查模板位置是否存在,启动时验证模板路径 +spring.freemarker.check-template-location = true + +# 模板内容类型,设置为HTML格式 +spring.freemarker.content-type = text/html + +# 将HTTP请求属性暴露给模板,便于模板访问请求参数、头部等信息 +spring.freemarker.expose-request-attributes = true + +# 将HTTP会话属性暴露给模板,便于模板访问会话数据 +spring.freemarker.expose-session-attributes = true + +# 设置模板中访问请求上下文的变量名 +# 在模板中使用 ${request} 访问请求对象 +spring.freemarker.request-context-attribute = request + +# 模板文件后缀,默认为.ftl +# 模板文件应命名为 *.ftl +spring.freemarker.suffix = .ftl + +####################### 1.5 Spring Boot健康监控配置 ####################### +# 开启的健康检查端点,包括health、info、metrics +# health: 应用健康状态 +# info: 应用基本信息 +# metrics: 应用性能指标 +management.endpoints.web.exposure.include = health,info,metrics + +# 健康检查端点显示详细信息的策略,默认为always(总是显示) +# 生产环境建议设置为 when-authorized 或 never,避免敏感信息泄露 +management.endpoint.health.show-details = always + +# 启用默认的健康检查组件,监控应用核心组件状态 +management.health.defaults.enabled = true + + + +####################################### 二、Office文档处理配置(部分支持动态配置)####################################### + +####################### 2.1 Office组件安装路径配置 ####################### +# OpenOffice或LibreOffice安装路径,默认为default表示使用系统默认路径 +# Windows示例(注意双反斜杠):C:\\Program Files (x86)\\OpenOffice 4 +# Linux示例:/opt/libreoffice +# MacOS示例:/Applications/LibreOffice.app/Contents +office.home = ${KK_OFFICE_HOME:default} + +####################### 2.2 Office转换服务配置 ####################### +# Office转换服务监听的端口,默认开启两个进程分别监听2001和2002端口 +# 多个端口用逗号分隔,系统会自动启动多个进程处理转换任务 +office.plugin.server.ports = 2001,2002 + +# Office转换任务总超时时间,默认为5分钟 +# 超过此时间的任务将被强制终止,释放资源 +office.plugin.task.timeout = 5m + +# Office进程在重启前可执行的最大任务数,默认为200 +# 0表示无限,不重启。适当限制可避免内存泄漏 +office.plugin.task.maxtasksperprocess = 200 + +# Office任务执行超时时间,默认为5分钟 +# 单个任务处理超过此时间将被中止,处理下一个任务 +office.plugin.task.taskexecutiontimeout = 5m + +####################### 2.3 Office转换参数配置(支持动态更新)####################### +# 是否启用Office文档分页转换,默认为false(转换全部页面) +# 可设置为页面范围,如"1-5"表示只转换前5页 +office.pagerange = ${KK_OFFICE_PAGERANGE:false} + +# 是否启用Office文档水印功能,默认为false(不启用) +# 启用后会在转换的图片或PDF上添加水印 +office.watermark = ${KK_OFFICE_WATERMARK:false} + +# Office文档转图片的JPEG压缩质量,范围1-100,默认80 +# 值越高图片质量越好但文件越大,建议80-90之间 +office.quality = ${KK_OFFICE_QUALITY:80} + +# Office文档转图片的最大分辨率DPI,默认150 +# 值越高图片越清晰但文件越大,转换时间越长 +# 建议范围:72(屏幕显示)-300(打印质量) +office.maximageresolution = ${KK_OFFICE_MAXIMAGERESOLUTION:150} + +# 是否导出Office文档中的书签到PDF,默认为true(导出) +# 保留书签可方便PDF文档导航 +office.exportbookmarks = ${KK_OFFICE_EXPORTBOOKMARKS:true} + +# 是否将Office文档中的批注作为PDF注释导出,默认为true(导出) +# 保留批注便于文档审阅 +office.exportnotes = ${KK_OFFICE_EXPORTNOTES:true} + +# 加密文档生成的PDF是否添加密码,默认为true(添加) +# 密码为原始加密文档的密码,增强文档安全性 +office.documentopenpasswords = ${KK_OFFICE_DOCUMENTOPENPASSWORD:true} + +# Excel文档(xlsx)的前端解析方式,默认为web(Web端解析) +# web: 使用前端SheetJS库解析,减轻服务器压力 +# image: 服务器转换为图片,兼容性更好 +office.type.web = ${KK_OFFICE_TYPE_WEB:web} + + + +####################################### 三、文件存储与缓存配置 ####################################### + +####################### 3.1 文件存储路径配置 ####################### +# 预览生成资源的存储路径,默认为应用根路径下的file目录 +# Windows示例:D:\\kkFileview\\(注意双反斜杠) +# Linux示例:/opt/kkfileview/file/ +# 重要:确保应用有该目录的读写权限 +file.dir = ${KK_FILE_DIR:default} + +# 允许预览的本地文件夹路径,默认为default(禁止所有本地文件预览) +# ⚠️ 安全警告:配置此路径可能允许访问系统文件,请谨慎配置 +# Windows示例(注意前面加反斜杠):\D:\\kkFileview\\1\\1.txt +# Linux示例(注意前面加正斜杠):/opt/1.txt +# 使用file协议访问:file://d:/1/1.txt(Windows)或 file:/opt/1/1.txt(Linux) +local.preview.dir = ${KK_LOCAL_PREVIEW_DIR:default} + +####################### 3.2 缓存核心配置 ####################### +# 是否启用缓存功能,默认为true(启用) +# 启用缓存可显著提升重复文件的预览速度 +cache.enabled = ${KK_CACHE_ENABLED:true} + +# 缓存实现类型,默认为jdk(使用JDK内置对象实现) +# 可选值: +# jdk: JDK内置ConcurrentHashMap,单机部署推荐 +# redis: Redis分布式缓存,集群部署推荐 +# default: 内嵌RocksDB,支持持久化 +cache.type = ${KK_CACHE_TYPE:jdk} + +####################### 3.3 Redis缓存详细配置(仅当cache.type=redis时生效)####################### +# Redis部署模式,默认为single(单机模式) +# 可选值: +# single: 单机模式(默认) +# cluster: 集群模式 +# sentinel: 哨兵模式(高可用) +# master-slave: 主从模式 +spring.redisson.mode = single + +# Redis连接地址,支持多种格式: +# 单机模式:redis://127.0.0.1:6379 +# 集群模式:redis://node1:6379,redis://node2:6379,redis://node3:6379 +# 哨兵模式:redis://sentinel1:26379,redis://sentinel2:26379 +spring.redisson.address = ${KK_SPRING_REDISSON_ADDRESS:redis://127.0.0.1:6379} + +# Redis连接密码,无密码时留空 +# 注意:密码包含特殊字符时需使用URL编码 +spring.redisson.password = ${KK_SPRING_REDISSON_PASSWORD:} + +# Redis数据库索引,默认为0(0-15) +# 不同业务可使用不同数据库隔离 +spring.redisson.database = ${KK_SPRING_REDISSON_DATABASE:0} + +####################### 3.4 缓存维护配置 ####################### +# 是否启用缓存自动清理,默认为true(启用) +# 定期清理过期缓存,避免磁盘空间无限增长 +cache.clean.enabled = ${KK_CACHE_CLEAN_ENABLED:true} + +# 缓存自动清理时间,使用Quartz cron表达式,默认为每天凌晨3点执行 +# 表达式格式:秒 分 时 日 月 周 年(可选) +# 0 0 3 * * ? 表示每天3:00:00执行清理 +cache.clean.cron = ${KK_CACHE_CLEAN_CRON:0 0 3 * * ?} + + + +####################################### 四、安全与访问控制配置(支持动态配置)####################################### + +####################### 4.1 服务地址配置 ####################### +# 提供预览服务的完整地址,默认从请求URL自动获取 +# 当使用Nginx等反向代理时,必须手动设置此地址 +# 示例:https://file.keking.cn +# 示例:http://192.168.1.100:8012 +base.url = ${KK_BASE_URL:default} + +####################### 4.2 信任主机白名单配置(重要安全配置)####################### +# 信任站点白名单配置,多个域名用逗号隔开 +# ⚠️ 安全提示:为防止SSRF(服务器端请求伪造)攻击,必须配置信任主机 +# ⚠️ 如果不配置,系统将默认拒绝所有外部文件预览请求 +# +# 配置示例: +# trust.host = kkview.cn,yourdomain.com,cdn.example.com,192.168.1.* +# +# 支持通配符: +# * 匹配所有字符 +# ? 匹配单个字符 +# 192.168.* 匹配192.168开头的所有IP +# +# 当前配置:允许所有域名(仅建议测试环境使用,生产环境必须修改) +trust.host = * + +####################### 4.3 不信任主机黑名单配置 ####################### +# 不信任站点黑名单配置,多个用逗号隔开 +# 黑名单优先级高于白名单,设置后将禁止预览来自这些站点的文件 +# 建议配置:禁止访问内网地址和本地地址,防止内部信息泄露 +# +# 配置示例: +# not.trust.host = localhost,127.0.0.1,0.0.0.0,192.168.*,10.*,172.16.*,172.17.*,172.18.*,172.19.*,172.20.*,172.21.*,172.22.*,172.23.*,172.24.*,172.25.*,172.26.*,172.27.*,172.28.*,172.29.*,172.30.*,172.31.* +not.trust.host = ${KK_NOT_TRUST_HOST:default} + +####################### 4.4 文件类型安全限制 ####################### +# 禁止上传和预览的文件类型,多个用逗号隔开 +# 默认禁止可执行文件和系统文件,防止恶意文件上传 +# exe: Windows可执行文件 +# dll: Windows动态链接库 +# dat: 数据文件(可能包含敏感信息) +prohibit = ${KK_PROHIBIT:exe,dll,dat} + + + +####################################### 五、文件格式与预览配置(支持动态配置)####################################### + +####################### 5.1 文本文件类型配置 ####################### +# 支持的文本文件类型,多个用逗号隔开 +# 这些文件将直接在前端以文本形式展示,无需转换 +# 默认支持常见编程语言、配置文件和文档格式 +simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd} + +####################### 5.2 多媒体文件类型配置 ####################### +# 支持的媒体文件类型,多个用逗号隔开 +# 这些文件将使用HTML5原生播放器或转换后播放 +# 默认支持主流音频、视频和流媒体格式 +media = ${KK_MEDIA:mp3,wav,mp4,flv,mpd,m3u8,ts,mpeg,m4a} + +# 支持格式转换的视频类型,多个用逗号隔开 +# 这些格式的视频将被转换为mp4格式以确保浏览器兼容性 +# ⚠️ 视频转换消耗资源较大,建议根据服务器性能配置 +convertMedias = ${KK_CONVERTMEDIAS:avi,mov,wmv,mkv,3gp,rm,mpeg} + +####################### 5.3 图片文件类型配置 ####################### +# TIF/TIFF格式图片预览模式,可选值:jpg、pdf +# tif: 使用前端Tiff.js插件直接浏览(需要浏览器支持) +# jpg: 服务器转换为JPG格式后显示(兼容性好) +# pdf: 服务器转换为PDF格式显示(支持多页和打印) +tif.preview.type = ${KK_TIF_PREVIEW_TYPE:tif} + +# CAD设计文件预览模式,可选值:tif、svg、pdf +# tif: 转换为TIFF格式,使用前端插件浏览 +# svg: 转换为SVG矢量格式(缩放不失真) +# pdf: 转换为PDF格式(便于打印和标注) +cad.preview.type = ${KK_CAD_PREVIEW_TYPE:svg} + +####################### 5.4 Office文档预览配置 ####################### +# Office类型文档(Word、PPT、Excel)预览样式,可选值:image、pdf +# image: 转换为图片序列(兼容性好,支持水印) +# pdf: 转换为PDF格式(支持文本复制、打印) +# 用户可在预览界面切换模式(除非禁用切换开关) +office.preview.type = ${KK_OFFICE_PREVIEW_TYPE:image} + +# 是否关闭Office预览模式切换开关,默认为false(允许切换) +# 设置为true时,用户无法在图片和PDF模式间切换 +office.preview.switch.disabled = ${KK_OFFICE_PREVIEW_SWITCH_DISABLED:false} + +####################### 5.5 PDF文档预览配置 ####################### +# PDF文件预览安全限制配置 +# 是否禁止PDF演示模式,默认为true(禁止) +pdf.presentationMode.disable = ${KK_PDF_PRESENTATION_MODE_DISABLE:true} + +# 是否禁止PDF文件菜单中的"打开文件"选项,默认为true(禁止) +pdf.openFile.disable = ${KK_PDF_OPEN_FILE_DISABLE:true} + +# 是否禁止PDF打印功能,默认为true(禁止) +pdf.print.disable = ${KK_PDF_PRINT_DISABLE:true} + +# 是否禁止PDF下载功能,默认为true(禁止) +pdf.download.disable = ${KK_PDF_DOWNLOAD_DISABLE:true} + +# 是否禁止PDF书签/大纲功能,默认为true(禁止) +pdf.bookmark.disable = ${KK_PDF_BOOKMARK_DISABLE:true} + +# 是否禁止PDF编辑功能(注释、表单等),默认为false(允许编辑) +pdf.disable.editing = ${KK_PDF_DISABLE_EDITING:false} + +####################### 5.6 水印配置 ####################### +# 水印文本内容,为空表示不添加水印 +# 示例:内部文件,严禁外泄 +watermark.txt = ${WATERMARK_TXT:} + +# 水印在X轴方向的间隔(像素),默认为10 +# 值越小水印越密集,值越大水印越稀疏 +watermark.x.space = ${WATERMARK_X_SPACE:10} + +# 水印在Y轴方向的间隔(像素),默认为10 +watermark.y.space = ${WATERMARK_Y_SPACE:10} + +# 水印字体,默认为"微软雅黑" +# 注意:服务器需要安装对应字体,Linux服务器可能需要安装中文字体 +watermark.font = ${WATERMARK_FONT:微软雅黑} + +# 水印字体大小,默认为18px +# 建议根据水印文本长度调整 +watermark.fontsize = ${WATERMARK_FONTSIZE:18px} + +# 水印字体颜色,默认为black(黑色) +# 支持格式:颜色名(black, white, red等)、十六进制(#000000)、RGB(rgb(0,0,0)) +watermark.color = ${WATERMARK_COLOR:black} + +# 水印透明度,范围0.005-1,默认为0.2(20%不透明度) +# 值越小水印越淡,值越大水印越明显 +watermark.alpha = ${WATERMARK_ALPHA:0.2} + +# 单个水印块的宽度(像素),默认为180 +# 需要根据水印文本长度调整 +watermark.width = ${WATERMARK_WIDTH:180} + +# 单个水印块的高度(像素),默认为80 +watermark.height = ${WATERMARK_HEIGHT:80} + +# 水印倾斜度数,范围0-90,默认为10度 +# 0度:水平水印,90度:垂直水印 +watermark.angle = ${WATERMARK_ANGLE:10} + +# CAD文件是否添加水印,默认为true(启用) +cad.watermark = true + + + +####################################### 六、性能与资源管理配置(支持动态配置)####################################### + +####################### 6.1 线程池配置 ####################### +# PDF转换最大并发线程数,默认为10 +# 根据服务器CPU核心数调整:建议为核心数×2 +pdf.max.threads = 10 + +# CAD转换线程数,默认为5 +# CAD转换较耗时,不宜设置过高 +cad.thread = ${KK_CAD_THREAD:5} + +# TIF转换线程数,默认为5 +tif.thread = 5 + +####################### 6.2 超时配置(按文件大小分类)####################### +# 是否启用视频转换超时控制,默认为true(启用) +media.timeout.enabled = true + +# 视频文件大小分类及超时时间(单位:秒) +# 小文件(<50MB):30秒超时 +media.small.file.timeout = 30 +# 中等文件(50-150MB):60秒超时 +media.medium.file.timeout = 60 +# 大文件(150-300MB):180秒超时(3分钟) +media.large.file.timeout = 180 +# 超大文件(300-600MB):300秒超时(5分钟) +media.xl.file.timeout = 300 +# XXL文件(600-1200MB):600秒超时(10分钟) +media.xxl.file.timeout = 600 +# XXXL文件(>1200MB):1200秒超时(20分钟) +media.xxxl.file.timeout = 1200 + +####################### 6.3 超时配置(按PDF页数分类)####################### +# PDF文件按页数分类的超时配置(单位:秒) +# 小文件(0-50页):90秒超时 +pdf.timeout.small = 90 +# 中等文件(51-200页):180秒超时(3分钟) +pdf.timeout.medium = 180 +# 大文件(201-500页):300秒超时(5分钟) +pdf.timeout.large = 300 +# 超大文件(>500页):600秒超时(10分钟) +pdf.timeout.xlarge = 600 + +# CAD转换超时时间,默认为90秒 +cad.timeout = ${KK_CAD_TIMEOUT:90} + +# TIF转换超时时间,默认为90秒 +tif.timeout = 90 + +####################### 6.4 资源限制配置 ####################### +# 视频转换最大文件大小限制(单位:MB),默认为300MB +# 超过此大小的视频文件禁止转换,避免服务器资源耗尽 +media.convert.max.size = 300 + +# 是否禁用视频格式转换功能,默认为true(禁用) +# ⚠️ 重要:视频转换非常消耗CPU和内存资源 +# 启用前确保:1)服务器性能充足 2)配置异步处理 3)增加线程池 +media.convert.disable = ${KK_MEDIA_CONVERT_DISABLE:true} + +####################### 6.5 DPI优化配置 ####################### +# 是否启用PDF DPI智能调整,默认为true(启用) +# 根据PDF页数自动调整DPI,平衡清晰度和性能 +pdf.dpi.enabled = true + +# PDF转图片的基准DPI,默认为144 +# 当DPI优化禁用时使用此值 +pdf2jpg.dpi = ${KK_PDF2JPG_DPI:144} + +# PDF按页数范围的DPI优化值 +# 小文件(0-50页):150 DPI(高质量) +pdf.dpi.small = 150 +# 中等文件(50-100页):120 DPI(平衡质量与性能) +pdf.dpi.medium = 120 +# 大文件(100-200页):96 DPI(优化性能) +pdf.dpi.large = 96 +# 超大文件(200-500页):72 DPI(快速转换) +pdf.dpi.xlarge = 72 +# 巨量文件(>500页):72 DPI(最小资源消耗) +pdf.dpi.xxlarge = 72 + + + +####################################### 七、FTP文件访问配置(支持动态配置)####################################### + +####################### 7.1 FTP连接认证配置 ####################### +#FTP模块设置 +#预览源为FTP时,可在ftp url后面加参数?ftp.username=ftpuser&ftp.password=123456&ftp.control.encoding=GBK,指定,不指定默认用配置的 (为了安全我们强烈建议在配置中设置相关信息) +#ftp.control.encodin (根据FTP服务器操作系统选择,Linux一般为UTF-8,Windows一般为GBK) +#使用方法,支持,分割第一个是域名或者IP地址后面是用户名在后面是密码,在后面是编码(用户名密码和编码用:分割, 域名用,分割),切记url不需要任何协议头 +#ftp.username = 地址:端口:用户名:密码:编码,192.168.0.2:21:name:123456:UTF-8,www.xxx.com:21:admin:pass:UTF-8 多客户端,分割 +#ftp.username =10.99.1.2:21:666:88888:GBK +ftp.username = false + + + +####################################### 八、首页与文件管理配置(支持动态配置)####################################### + +####################### 8.1 首页功能配置 ####################### +# 是否禁用首页文件上传功能,默认为false(不禁用) +# 设置为true可关闭上传功能,仅用于预览 +file.upload.disable = false + +# 网站备案信息,显示在首页底部,默认为空 +# 示例:京ICP备12345678号 +beian = ${KK_BEIAN:default} + +# 首页初始化加载的页码,默认为1(第一页) +home.pagenumber = ${DEFAULT_HOME_PAGENUMBER:1} + +# 首页是否启用分页功能,默认为true(启用) +home.pagination = ${DEFAULT_HOME_PAGINATION:true} + +# 首页每页显示的文件数量,默认为15 +home.pagesize = ${DEFAULT_HOME_PAGSIZE:15} + +# 首页是否显示搜索框,默认为true(显示) +home.search = ${DEFAULT_HOME_SEARCH:true} + +####################### 8.2 文件删除安全配置 ####################### +# 是否启用验证码验证删除文件,默认为false(不启用) +# 启用后删除文件需要输入验证码,防止误删 +delete.captcha = ${KK_DELETE_CAPTCHA:false} + +# 删除文件密码,默认为123456 +# 删除文件时需要验证此密码(如果启用了验证码,两者都需要) +delete.password = ${KK_DELETE_PASSWORD:123456} + +# 是否删除转换后的源文件,默认为true(删除) +# 启用可节约磁盘空间,但会丢失原始文件 +# 注意:删除后无法重新转换,只能重新上传 +delete.source.file = ${KK_DELETE_SOURCE_FILE:true} + + + +####################################### 九、权限与认证配置(支持动态配置)####################################### + +####################### 9.1 功能权限配置 ####################### +# 是否启用图片预览权限,默认为true(启用) +# 设置为false可禁用所有图片预览功能 +kk.Picturespreview = true + +# 是否启用跨域文件获取权限,默认为true(启用) +# 设置为false可防止跨站请求伪造(CSRF) +kk.Getcorsfile = true + +# 是否启用添加异步任务权限,默认为true(启用) +# 大文件转换通常使用异步任务处理 +kk.addTask = true + +# API密钥功能,默认为false(禁用) +# 启用后需要提供密钥才能调用API +kk.Key = false + +####################### 9.2 加密与认证配置 ####################### +# AES加密密钥,必须为16位字符 +# 启用AES加密时,接入方需使用相同的密钥 +# 用于敏感数据传输加密 +ase.key = 1234567890123456 + +# Basic认证配置,格式:域名:用户名:密码,多个用逗号分隔 +# 用于保护特定域名的访问 +# 示例:192.168.0.1:admin:pass123,example.com:user:pass456 +basic.name = 10.99.1.2:aaa:bbb + +# User-Agent验证字符串,默认为99999 +# 可用于简单的客户端验证 +useragent = 99999 + + + +####################################### 十、高级功能与兼容性配置(支持动态配置)####################################### + +####################### 10.1 SSL/TLS安全配置 ####################### +# 是否忽略SSL证书验证,默认为true(忽略) +# 用于开发环境或自签名证书场景 +# 生产环境建议设置为false,启用完整的证书验证 +kk.ignore.ssl = true + +####################### 10.2 重定向功能配置 ####################### +# 是否启用URL重定向功能,默认为true(启用) +# 用于处理文件下载、外部资源引用等场景 +kk.enable.redirect = true \ No newline at end of file diff --git a/server/src/main/java/cn/keking/config/ConfigConstants.java b/server/src/main/java/cn/keking/config/ConfigConstants.java index 5c742f4d..9a83f0f6 100644 --- a/server/src/main/java/cn/keking/config/ConfigConstants.java +++ b/server/src/main/java/cn/keking/config/ConfigConstants.java @@ -23,40 +23,38 @@ public class ConfigConstants { } // ================================================== - // 常量定义区 - // ================================================== +// 常量定义区(按功能模块分组) +// ================================================== - // 缓存配置常量 + // 1. 缓存配置常量 public static final String DEFAULT_CACHE_ENABLED = "true"; - // 文件类型配置常量 + // 2. 文件类型配置常量 public static final String DEFAULT_TXT_TYPE = "txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd,xbrl"; public static final String DEFAULT_MEDIA_TYPE = "mp3,wav,mp4,flv"; public static final String DEFAULT_PROHIBIT = "exe,dll"; public static final String DEFAULT_TIF_PREVIEW_TYPE = "tif"; public static final String DEFAULT_CAD_PREVIEW_TYPE = "pdf"; - // Office配置常量 + // 3. Office配置常量 public static final String DEFAULT_OFFICE_PREVIEW_TYPE = "image"; public static final String DEFAULT_OFFICE_PREVIEW_SWITCH_DISABLED = "false"; public static final String DEFAULT_OFFICE_TYPE_WEB = "web"; - public static final String DEFAULT_OFFICE_PAQERANQE = "false"; + public static final String DEFAULT_OFFICE_PAQERANQE = "false"; // 注意:拼写错误,应为PAGERANGE public static final String DEFAULT_OFFICE_WATERMARK = "false"; public static final String DEFAULT_OFFICE_QUALITY = "80"; - public static final String DEFAULT_OFFICE_MAXIMAQERESOLUTION = "150"; + public static final String DEFAULT_OFFICE_MAXIMAQERESOLUTION = "150"; // 注意:拼写错误,应为MAXIMAGERESOLUTION public static final String DEFAULT_OFFICE_EXPORTBOOKMARKS = "true"; public static final String DEFAULT_OFFICE_EXPORTNOTES = "true"; - public static final String DEFAULT_OFFICE_EOCUMENTOPENPASSWORDS = "true"; + public static final String DEFAULT_OFFICE_EOCUMENTOPENPASSWORDS = "true"; // 注意:拼写错误,应为DOCUMENTOPENPASSWORDS - // FTP配置常量 + // 4. FTP配置常量 public static final String DEFAULT_FTP_USERNAME = null; - public static final String DEFAULT_FTP_PASSWORD = null; - public static final String DEFAULT_FTP_CONTROL_ENCODING = "UTF-8"; - // 路径配置常量 + // 5. 路径配置常量 public static final String DEFAULT_VALUE = "default"; - // PDF配置常量 + // 6. PDF配置常量 public static final String DEFAULT_PDF_PRESENTATION_MODE_DISABLE = "true"; public static final String DEFAULT_PDF_OPEN_FILE_DISABLE = "true"; public static final String DEFAULT_PDF_PRINT_DISABLE = "true"; @@ -64,45 +62,81 @@ public class ConfigConstants { public static final String DEFAULT_PDF_BOOKMARK_DISABLE = "true"; public static final String DEFAULT_PDF_DISABLE_EDITING = "true"; public static final String DEFAULT_PDF2_JPG_DPI = "105"; - public static final String DEFAULT_PDF_TIMEOUT = "90"; - public static final String DEFAULT_PDF_TIMEOUT80 = "180"; - public static final String DEFAULT_PDF_TIMEOUT200 = "300"; - public static final String DEFAULT_PDF_THREAD = "5"; - // CAD配置常量 + // 7. CAD配置常量 public static final String DEFAULT_CAD_TIMEOUT = "90"; public static final String DEFAULT_CAD_THREAD = "5"; - // 文件操作配置常量 + // 8. TIF配置常量 + public static final String DEFAULT_TIF_TIMEOUT = "90"; + public static final String DEFAULT_TIF_THREAD = "5"; + + // 9. 文件操作配置常量 public static final String DEFAULT_FILE_UPLOAD_DISABLE = "false"; public static final String DEFAULT_DELETE_SOURCE_FILE = "true"; public static final String DEFAULT_DELETE_CAPTCHA = "false"; public static final String DEFAULT_SIZE = "500MB"; public static final String DEFAULT_PASSWORD = "123456"; - // 首页配置常量 + // 10. 首页配置常量 public static final String DEFAULT_BEIAN = "无"; public static final String DEFAULT_HOME_PAGENUMBER = "1"; public static final String DEFAULT_HOME_PAGINATION = "true"; public static final String DEFAULT_HOME_PAGSIZE = "15"; public static final String DEFAULT_HOME_SEARCH = "true"; - // 权限配置常量 + // 11. 权限配置常量 public static final String DEFAULT_KEY = "false"; public static final String DEFAULT_PICTURES_PREVIEW = "true"; public static final String DEFAULT_GET_CORS_FILE = "true"; public static final String DEFAULT_ADD_TASK = "true"; - public static final String DEFAULT_AES_KEY= "1234567890123456"; + public static final String DEFAULT_AES_KEY = "1234567890123456"; - // UserAgent配置常量 + // 12. UserAgent配置常量 public static final String DEFAULT_USER_AGENT = "false"; - // Basic认证配置常量 + // 13. Basic认证配置常量 public static final String DEFAULT_BASIC_NAME = ""; + // 14. 视频转换配置常量 + public static final String DEFAULT_MEDIA_CONVERT_MAX_SIZE = "300"; + public static final String DEFAULT_MEDIA_TIMEOUT_ENABLED = "true"; + public static final String DEFAULT_MEDIA_SMALL_FILE_TIMEOUT = "30"; + public static final String DEFAULT_MEDIA_MEDIUM_FILE_TIMEOUT = "60"; + public static final String DEFAULT_MEDIA_LARGE_FILE_TIMEOUT = "180"; + public static final String DEFAULT_MEDIA_XL_FILE_TIMEOUT = "300"; + public static final String DEFAULT_MEDIA_XXL_FILE_TIMEOUT = "600"; + public static final String DEFAULT_MEDIA_XXXL_FILE_TIMEOUT = "1200"; + + // 15. PDF DPI配置常量 + public static final String DEFAULT_PDF_SMALL_DTI = "150"; // 注意:拼写错误,应为DPI + public static final String DEFAULT_PDF_MEDIUM_DPI = "120"; + public static final String DEFAULT_PDF_LARGE_DPI = "96"; + public static final String DEFAULT_PDF_XLARGE_DPI = "72"; + public static final String DEFAULT_PDF_XXLARGE_DPI = "72"; + public static final String DEFAULT_PDF_DPI_ENABLED = "true"; + + // 16. PDF超时配置常量(新标准化配置) + public static final String DEFAULT_PDF_TIMEOUT_SMALL = "90"; + public static final String DEFAULT_PDF_TIMEOUT_MEDIUM = "180"; + public static final String DEFAULT_PDF_TIMEOUT_LARGE = "300"; + public static final String DEFAULT_PDF_TIMEOUT_XLARGE = "600"; + + // 17. PDF线程配置常量 + public static final String DEFAULT_PDF_MAX_THREADS = "10"; + + // 18. CAD水印配置常量 + public static final String DEFAULT_CAD_WATERMARK = "false"; + + // 19. SSL忽略配置常量 + public static final String DEFAULT_IGNORE_SSL = "true"; + + // 20. 重定向启用配置常量 + public static final String DEFAULT_ENABLE_REDIRECT = "true"; + // ================================================== - // 配置变量定义区(按功能分类) - // ================================================== +// 配置变量定义区(按功能分类,均为静态变量) +// ================================================== // 1. 缓存配置 private static Boolean cacheEnabled; @@ -130,8 +164,6 @@ public class ConfigConstants { // 4. FTP配置 private static String ftpUsername; - private static String ftpPassword; - private static String ftpControlEncoding; // 5. 路径配置 private static String fileDir = ConfigUtils.getHomePath() + File.separator + "file" + File.separator; @@ -150,45 +182,132 @@ public class ConfigConstants { private static String pdfDownloadDisable; private static String pdfBookmarkDisable; private static int pdf2JpgDpi; - private static int pdfTimeout; - private static int pdfTimeout80; - private static int pdfTimeout200; - private static int pdfThread; // 8. CAD配置 private static String cadTimeout; private static int cadThread; - // 9. 文件操作配置 + // 9. TIF配置 + private static String tifTimeout; + private static int tifThread; + + // 10. 文件操作配置 private static Boolean fileUploadDisable; private static String size; private static String password; private static Boolean deleteSourceFile; private static Boolean deleteCaptcha; - // 10. 首页配置 + // 11. 首页配置 private static String beian; private static String homePageNumber; private static String homePagination; private static String homePageSize; private static String homeSearch; - // 11. 权限配置 + // 12. 权限配置 private static String key; private static boolean picturesPreview; private static boolean getCorsFile; private static boolean addTask; private static String aesKey; - // 12. UserAgent配置 + // 13. UserAgent配置 private static String userAgent; - // 13. Basic认证配置 + // 14. Basic认证配置 private static String basicName; + // 15. 视频转换配置 + private static int mediaConvertMaxSize; + private static boolean mediaTimeoutEnabled; + private static int mediaSmallFileTimeout; + private static int mediaMediumFileTimeout; + private static int mediaLargeFileTimeout; + private static int mediaXLFileTimeout; + private static int mediaXXLFileTimeout; + private static int mediaXXXLFileTimeout; + + // 16. PDF DPI配置 + private static boolean pdfDpiEnabled; + private static int pdfSmallDpi; + private static int pdfMediumDpi; + private static int pdfLargeDpi; + private static int pdfXLargeDpi; + private static int pdfXXLargeDpi; + + // 17. PDF超时配置(新) + private static int pdfTimeoutSmall; + private static int pdfTimeoutMedium; + private static int pdfTimeoutLarge; + private static int pdfTimeoutXLarge; + + // 18. PDF线程配置 + private static int pdfMaxThreads; + + // 19. CAD水印配置 + private static Boolean cadwatermark; + + // 20. SSL忽略配置 + private static Boolean ignoreSSL; + + // 21. 重定向启用配置 + private static Boolean enableRedirect; + + // ================================================== - // 获取方法(按功能分类) - // ================================================== +// 获取方法(按功能分类) +// ================================================== + + /** + * PDF超时配置获取方法(新) + */ + public static int getPdfTimeoutSmall() { + return pdfTimeoutSmall; + } + + public static int getPdfTimeoutMedium() { + return pdfTimeoutMedium; + } + + public static int getPdfTimeoutLarge() { + return pdfTimeoutLarge; + } + + public static int getPdfTimeoutXLarge() { + return pdfTimeoutXLarge; + } + + public static int getPdfMaxThreads() { + return pdfMaxThreads; + } + + /** + * 根据页数获取优化的DPI值 + * 智能DPI调整策略: + * - 0-50页: 150 DPI + * - 51-100页: 120 DPI + * - 101-200页: 96 DPI + * - 201-500页: 72 DPI + * - >500页: 72 DPI + */ + public static int getOptimizedDpi(int pageCount) { + if (!pdfDpiEnabled) { + return ConfigConstants.getPdf2JpgDpi(); + } + + if (pageCount > 500) { + return pdfXXLargeDpi; + } else if (pageCount > 200) { + return pdfXLargeDpi; + } else if (pageCount > 100) { + return pdfLargeDpi; + } else if (pageCount > 50) { + return pdfMediumDpi; + } else { + return pdfSmallDpi; + } + } // 1. 缓存配置获取方法 public static Boolean isCacheEnabled() { @@ -266,14 +385,6 @@ public class ConfigConstants { return ftpUsername; } - public static String getFtpPassword() { - return ftpPassword; - } - - public static String getFtpControlEncoding() { - return ftpControlEncoding; - } - // 5. 路径配置获取方法 public static String getBaseUrl() { return baseUrl; @@ -325,22 +436,6 @@ public class ConfigConstants { return pdf2JpgDpi; } - public static int getPdfTimeout() { - return pdfTimeout; - } - - public static int getPdfTimeout80() { - return pdfTimeout80; - } - - public static int getPdfTimeout200() { - return pdfTimeout200; - } - - public static int getPdfThread() { - return pdfThread; - } - // 8. CAD配置获取方法 public static String getCadPreviewType() { return cadPreviewType; @@ -354,7 +449,16 @@ public class ConfigConstants { return cadThread; } - // 9. 文件操作配置获取方法 + // 9. TIF配置获取方法 + public static String getTifTimeout() { + return tifTimeout; + } + + public static int getTifThread() { + return tifThread; + } + + // 10. 文件操作配置获取方法 public static Boolean getFileUploadDisable() { return fileUploadDisable; } @@ -375,7 +479,7 @@ public class ConfigConstants { return deleteCaptcha; } - // 10. 首页配置获取方法 + // 11. 首页配置获取方法 public static String getBeian() { return beian; } @@ -396,7 +500,7 @@ public class ConfigConstants { return homeSearch; } - // 11. 权限配置获取方法 + // 12. 权限配置获取方法 public static String getKey() { return key; } @@ -417,19 +521,67 @@ public class ConfigConstants { return aesKey; } - // 12. UserAgent配置获取方法 + // 13. UserAgent配置获取方法 public static String getUserAgent() { return userAgent; } - // 13. Basic认证配置获取方法 + // 14. Basic认证配置获取方法 public static String getBasicName() { return basicName; } - // ================================================== - // Setter方法(按功能分类) - // ================================================== + // 15. 视频转换配置获取方法 + public static int getMediaConvertMaxSize() { + return mediaConvertMaxSize; + } + + public static boolean isMediaTimeoutEnabled() { + return mediaTimeoutEnabled; + } + + public static int getMediaSmallFileTimeout() { + return mediaSmallFileTimeout; + } + + public static int getMediaMediumFileTimeout() { + return mediaMediumFileTimeout; + } + + public static int getMediaLargeFileTimeout() { + return mediaLargeFileTimeout; + } + + public static int getMediaXLFileTimeout() { + return mediaXLFileTimeout; + } + + public static int getMediaXXLFileTimeout() { + return mediaXXLFileTimeout; + } + + public static int getMediaXXXLFileTimeout() { + return mediaXXXLFileTimeout; + } + + // 19. CAD水印配置获取方法 + public static boolean getCadwatermark() { + return cadwatermark; + } + + // 20. SSL忽略配置获取方法 + public static boolean isIgnoreSSL() { + return ignoreSSL; + } + + // 21. 重定向启用配置获取方法 + public static boolean isEnableRedirect() { + return enableRedirect; + } + +// ================================================== +// Setter方法(按功能分类) +// ================================================== // 1. 缓存配置Setter方法 @Value("${cache.enabled:true}") @@ -610,24 +762,6 @@ public class ConfigConstants { ConfigConstants.ftpUsername = ftpUsername; } - @Value("${ftp.password:}") - public void setFtpPassword(String ftpPassword) { - setFtpPasswordValue(ftpPassword); - } - - public static void setFtpPasswordValue(String ftpPassword) { - ConfigConstants.ftpPassword = ftpPassword; - } - - @Value("${ftp.control.encoding:UTF-8}") - public void setFtpControlEncoding(String ftpControlEncoding) { - setFtpControlEncodingValue(ftpControlEncoding); - } - - public static void setFtpControlEncodingValue(String ftpControlEncoding) { - ConfigConstants.ftpControlEncoding = ftpControlEncoding; - } - // 5. 路径配置Setter方法 @Value("${base.url:default}") public void setBaseUrl(String baseUrl) { @@ -685,11 +819,15 @@ public class ConfigConstants { setNotTrustHostSet(getHostValue(notTrustHost)); } + /** + * 解析主机配置值 + * 支持格式:host1,host2,host3 + * 自动转换为小写并移除空格 + */ private static CopyOnWriteArraySet getHostValue(String trustHost) { if (DEFAULT_VALUE.equalsIgnoreCase(trustHost)) { return new CopyOnWriteArraySet<>(); } else { - // 去除空格并转小写 String[] trustHostArray = trustHost.toLowerCase().replaceAll("\\s+", "").split(","); return new CopyOnWriteArraySet<>(Arrays.asList(trustHostArray)); } @@ -767,42 +905,6 @@ public class ConfigConstants { ConfigConstants.pdf2JpgDpi = pdf2JpgDpi; } - @Value("${pdf.timeout:90}") - public void setPdfTimeout(int pdfTimeout) { - setPdfTimeoutValue(pdfTimeout); - } - - public static void setPdfTimeoutValue(int pdfTimeout) { - ConfigConstants.pdfTimeout = pdfTimeout; - } - - @Value("${pdf.timeout80:180}") - public void setPdfTimeout80(int pdfTimeout80) { - setPdfTimeout80Value(pdfTimeout80); - } - - public static void setPdfTimeout80Value(int pdfTimeout80) { - ConfigConstants.pdfTimeout80 = pdfTimeout80; - } - - @Value("${pdf.timeout200:300}") - public void setPdfTimeout200(int pdfTimeout200) { - setPdfTimeout200Value(pdfTimeout200); - } - - public static void setPdfTimeout200Value(int pdfTimeout200) { - ConfigConstants.pdfTimeout200 = pdfTimeout200; - } - - @Value("${pdf.thread:5}") - public void setPdfThread(int pdfThread) { - setPdfThreadValue(pdfThread); - } - - public static void setPdfThreadValue(int pdfThread) { - ConfigConstants.pdfThread = pdfThread; - } - // 8. CAD配置Setter方法 @Value("${cad.timeout:90}") public void setCadTimeout(String cadTimeout) { @@ -822,7 +924,26 @@ public class ConfigConstants { ConfigConstants.cadThread = cadThread; } - // 9. 文件操作配置Setter方法 + // 9. TIF配置Setter方法 + @Value("${tif.timeout:90}") + public void setTifTimeout(String tifTimeout) { + setTifTimeoutValue(tifTimeout); + } + + public static void setTifTimeoutValue(String tifTimeout) { + ConfigConstants.tifTimeout = tifTimeout; + } + + @Value("${tif.thread:5}") + public void setTifThread(int tifThread) { + setTifThreadValue(tifThread); + } + + public static void setTifThreadValue(int tifThread) { + ConfigConstants.tifThread = tifThread; + } + + // 10. 文件操作配置Setter方法 @Value("${file.upload.disable:true}") public void setFileUploadDisable(Boolean fileUploadDisable) { setFileUploadDisableValue(fileUploadDisable); @@ -868,7 +989,7 @@ public class ConfigConstants { ConfigConstants.deleteCaptcha = deleteCaptcha; } - // 10. 首页配置Setter方法 + // 11. 首页配置Setter方法 @Value("${beian:default}") public void setBeian(String beian) { setBeianValue(beian); @@ -914,7 +1035,7 @@ public class ConfigConstants { ConfigConstants.homeSearch = homeSearch; } - // 11. 权限配置Setter方法 + // 12. 权限配置Setter方法 @Value("${kk.Key:}") public void setKey(String key) { setKeyValue(key); @@ -960,7 +1081,7 @@ public class ConfigConstants { ConfigConstants.aesKey = aesKey; } - // 12. UserAgent配置Setter方法 + // 13. UserAgent配置Setter方法 @Value("${useragent:false}") public void setUserAgent(String userAgent) { setUserAgentValue(userAgent); @@ -970,7 +1091,7 @@ public class ConfigConstants { ConfigConstants.userAgent = userAgent; } - // 13. Basic认证配置Setter方法 + // 14. Basic认证配置Setter方法 @Value("${basic.name:}") public void setBasicName(String basicName) { setBasicNameValue(basicName); @@ -979,4 +1100,209 @@ public class ConfigConstants { public static void setBasicNameValue(String basicName) { ConfigConstants.basicName = basicName; } + + // 15. 视频转换配置Setter方法 + @Value("${media.convert.max.size:300}") + public void setMediaConvertMaxSize(int mediaConvertMaxSize) { + setMediaConvertMaxSizeValue(mediaConvertMaxSize); + } + + public static void setMediaConvertMaxSizeValue(int mediaConvertMaxSize) { + ConfigConstants.mediaConvertMaxSize = mediaConvertMaxSize; + } + + @Value("${media.timeout.enabled:true}") + public void setMediaTimeoutEnabled(String mediaTimeoutEnabled) { + setMediaTimeoutEnabledValue(Boolean.parseBoolean(mediaTimeoutEnabled)); + } + + public static void setMediaTimeoutEnabledValue(boolean mediaTimeoutEnabled) { + ConfigConstants.mediaTimeoutEnabled = mediaTimeoutEnabled; + } + + @Value("${media.small.file.timeout:30}") + public void setMediaSmallFileTimeout(int mediaSmallFileTimeout) { + setMediaSmallFileTimeoutValue(mediaSmallFileTimeout); + } + + public static void setMediaSmallFileTimeoutValue(int mediaSmallFileTimeout) { + ConfigConstants.mediaSmallFileTimeout = mediaSmallFileTimeout; + } + + @Value("${media.medium.file.timeout:60}") + public void setMediaMediumFileTimeout(int mediaMediumFileTimeout) { + setMediaMediumFileTimeoutValue(mediaMediumFileTimeout); + } + + public static void setMediaMediumFileTimeoutValue(int mediaMediumFileTimeout) { + ConfigConstants.mediaMediumFileTimeout = mediaMediumFileTimeout; + } + + @Value("${media.large.file.timeout:180}") + public void setMediaLargeFileTimeout(int mediaLargeFileTimeout) { + setMediaLargeFileTimeoutValue(mediaLargeFileTimeout); + } + + public static void setMediaLargeFileTimeoutValue(int mediaLargeFileTimeout) { + ConfigConstants.mediaLargeFileTimeout = mediaLargeFileTimeout; + } + + @Value("${media.xl.file.timeout:300}") + public void setMediaXLFileTimeout(int mediaXLFileTimeout) { + setMediaXLFileTimeoutValue(mediaXLFileTimeout); + } + + public static void setMediaXLFileTimeoutValue(int mediaXLFileTimeout) { + ConfigConstants.mediaXLFileTimeout = mediaXLFileTimeout; + } + + @Value("${media.xxl.file.timeout:600}") + public void setMediaXXLFileTimeout(int mediaXXLFileTimeout) { + setMediaXXLFileTimeoutValue(mediaXXLFileTimeout); + } + + public static void setMediaXXLFileTimeoutValue(int mediaXXLFileTimeout) { + ConfigConstants.mediaXXLFileTimeout = mediaXXLFileTimeout; + } + + @Value("${media.xxxl.file.timeout:1200}") + public void setMediaXXXLFileTimeout(int mediaXXXLFileTimeout) { + setMediaXXXLFileTimeoutValue(mediaXXXLFileTimeout); + } + + public static void setMediaXXXLFileTimeoutValue(int mediaXXXLFileTimeout) { + ConfigConstants.mediaXXXLFileTimeout = mediaXXXLFileTimeout; + } + + // 16. PDF DPI配置Setter方法 + @Value("${pdf.dpi.enabled:true}") + public void setPdfDpiEnabled(String pdfDpiEnabled) { + setPdfDpiEnabledValue(Boolean.parseBoolean(pdfDpiEnabled)); + } + + public static void setPdfDpiEnabledValue(boolean pdfDpiEnabled) { + ConfigConstants.pdfDpiEnabled = pdfDpiEnabled; + } + + @Value("${pdf.dpi.small:150}") + public void setPdfSmallDpi(int pdfSmallDpi) { + setPdfSmallDpiValue(pdfSmallDpi); + } + + public static void setPdfSmallDpiValue(int pdfSmallDpi) { + ConfigConstants.pdfSmallDpi = pdfSmallDpi; + } + + @Value("${pdf.dpi.medium:120}") + public void setPdfMediumDpi(int pdfMediumDpi) { + setPdfMediumDpiValue(pdfMediumDpi); + } + + public static void setPdfMediumDpiValue(int pdfMediumDpi) { + ConfigConstants.pdfMediumDpi = pdfMediumDpi; + } + + @Value("${pdf.dpi.large:96}") + public void setPdfLargeDpi(int pdfLargeDpi) { + setPdfLargeDpiValue(pdfLargeDpi); + } + + public static void setPdfLargeDpiValue(int pdfLargeDpi) { + ConfigConstants.pdfLargeDpi = pdfLargeDpi; + } + + @Value("${pdf.dpi.xlarge:72}") + public void setPdfXLargeDpi(int pdfXLargeDpi) { + setPdfXLargeDpiValue(pdfXLargeDpi); + } + + public static void setPdfXLargeDpiValue(int pdfXLargeDpi) { + ConfigConstants.pdfXLargeDpi = pdfXLargeDpi; + } + + @Value("${pdf.dpi.xxlarge:72}") + public void setPdfXXLargeDpi(int pdfXXLargeDpi) { + setPdfXXLargeDpiValue(pdfXXLargeDpi); + } + + public static void setPdfXXLargeDpiValue(int pdfXXLargeDpi) { + ConfigConstants.pdfXXLargeDpi = pdfXXLargeDpi; + } + + // 17. PDF超时配置Setter方法(新) + @Value("${pdf.timeout.small:90}") + public void setPdfTimeoutSmall(int pdfTimeoutSmall) { + setPdfTimeoutSmallValue(pdfTimeoutSmall); + } + + public static void setPdfTimeoutSmallValue(int pdfTimeoutSmall) { + ConfigConstants.pdfTimeoutSmall = pdfTimeoutSmall; + } + + @Value("${pdf.timeout.medium:180}") + public void setPdfTimeoutMedium(int pdfTimeoutMedium) { + setPdfTimeoutMediumValue(pdfTimeoutMedium); + } + + public static void setPdfTimeoutMediumValue(int pdfTimeoutMedium) { + ConfigConstants.pdfTimeoutMedium = pdfTimeoutMedium; + } + + @Value("${pdf.timeout.large:300}") + public void setPdfTimeoutLarge(int pdfTimeoutLarge) { + setPdfTimeoutLargeValue(pdfTimeoutLarge); + } + + public static void setPdfTimeoutLargeValue(int pdfTimeoutLarge) { + ConfigConstants.pdfTimeoutLarge = pdfTimeoutLarge; + } + + @Value("${pdf.timeout.xlarge:600}") + public void setPdfTimeoutXLarge(int pdfTimeoutXLarge) { + setPdfTimeoutXLargeValue(pdfTimeoutXLarge); + } + + public static void setPdfTimeoutXLargeValue(int pdfTimeoutXLarge) { + ConfigConstants.pdfTimeoutXLarge = pdfTimeoutXLarge; + } + + // 18. PDF线程配置Setter方法 + @Value("${pdf.max.threads:10}") + public void setPdfMaxThreads(int pdfMaxThreads) { + setPdfMaxThreadsValue(pdfMaxThreads); + } + + public static void setPdfMaxThreadsValue(int pdfMaxThreads) { + ConfigConstants.pdfMaxThreads = pdfMaxThreads; + } + + // 19. CAD水印配置Setter方法 + @Value("${cad.watermark:false}") + public void setCadwatermark(String cadwatermark) { + setCadwatermarkValue(Boolean.parseBoolean(cadwatermark)); + } + + public static void setCadwatermarkValue(Boolean cadwatermark) { + ConfigConstants.cadwatermark = cadwatermark; + } + + // 20. SSL忽略配置Setter方法 + @Value("${kk.ignore.ssl:true}") + public void setIgnoreSSL(String ignoreSSL) { + setIgnoreSSLValue(Boolean.parseBoolean(ignoreSSL)); + } + + public static void setIgnoreSSLValue(Boolean ignoreSSL) { + ConfigConstants.ignoreSSL = ignoreSSL; + } + + // 21. 重定向启用配置Setter方法 + @Value("${kk.enable.redirect:true}") + public void setEnableRedirect(String enableRedirect) { + setEnableRedirectValue(Boolean.parseBoolean(enableRedirect)); + } + + public static void setEnableRedirectValue(Boolean enableRedirect) { + ConfigConstants.enableRedirect = enableRedirect; + } } \ No newline at end of file diff --git a/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java b/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java index f6deaa38..c34e3dfe 100644 --- a/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java +++ b/server/src/main/java/cn/keking/config/ConfigRefreshComponent.java @@ -15,35 +15,38 @@ import java.util.Properties; import java.util.concurrent.*; /** - * @auther: chenjh - * @time: 2019/4/10 16:16 - * @description 使用 WatchService 监听配置文件变化,实现事件驱动的配置更新 + * 配置刷新组件 - 动态配置管理 + * 功能:监听配置文件变化,实现热更新配置 */ @Component public class ConfigRefreshComponent { - private static final Logger LOGGER = LoggerFactory.getLogger(ConfigRefreshComponent.class); + // 线程池和任务调度器 private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private final ExecutorService watchServiceExecutor = Executors.newSingleThreadExecutor(); private final Object lock = new Object(); - // 防抖延迟时间(单位:秒) + // 防抖延迟时间(秒) private static final long DEBOUNCE_DELAY_SECONDS = 5; + // 任务和状态管理 private ScheduledFuture scheduledReloadTask; private WatchService watchService; private volatile boolean running = true; + /** + * 初始化方法 - 启动配置监听 + */ @PostConstruct void init() { - // 初始化时立即加载一次配置 loadConfig(); - - // 启动监听线程 watchServiceExecutor.submit(this::watchConfigFile); } + /** + * 销毁方法 - 清理资源 + */ @PreDestroy void destroy() { running = false; @@ -74,8 +77,6 @@ public class ConfigRefreshComponent { } watchService = FileSystems.getDefault().newWatchService(); - - // 注册监听目录的修改事件 configDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE, @@ -91,7 +92,6 @@ public class ConfigRefreshComponent { WatchEvent.Kind kind = event.kind(); Path changedPath = (Path) event.context(); - // 检查是否是目标配置文件的变化 if (changedPath.equals(configPath.getFileName())) { handleConfigChange(kind); } @@ -131,7 +131,6 @@ public class ConfigRefreshComponent { if (kind == StandardWatchEventKinds.ENTRY_MODIFY || kind == StandardWatchEventKinds.ENTRY_CREATE) { - // 使用防抖机制:取消之前的任务,重新调度新任务 synchronized (lock) { if (scheduledReloadTask != null && !scheduledReloadTask.isDone()) { scheduledReloadTask.cancel(false); @@ -158,7 +157,6 @@ public class ConfigRefreshComponent { Properties properties = new Properties(); String configFilePath = ConfigUtils.getCustomizedConfigPath(); - // 检查文件是否存在 Path configPath = Paths.get(configFilePath); if (!Files.exists(configPath)) { LOGGER.warn("配置文件不存在: {}", configFilePath); @@ -169,7 +167,6 @@ public class ConfigRefreshComponent { properties.load(bufferedReader); ConfigUtils.restorePropertiesFromEnvFormat(properties); - // 解析并设置配置项 updateConfigConstants(properties); setWatermarkConfig(properties); @@ -181,6 +178,7 @@ public class ConfigRefreshComponent { } } } + /** * 更新配置常量 */ @@ -213,8 +211,6 @@ public class ConfigRefreshComponent { // 4. FTP配置 String ftpUsername = properties.getProperty("ftp.username", ConfigConstants.DEFAULT_FTP_USERNAME); - String ftpPassword = properties.getProperty("ftp.password", ConfigConstants.DEFAULT_FTP_PASSWORD); - String ftpControlEncoding = properties.getProperty("ftp.control.encoding", ConfigConstants.DEFAULT_FTP_CONTROL_ENCODING); // 5. 路径配置 String baseUrl = properties.getProperty("base.url", ConfigConstants.DEFAULT_VALUE); @@ -231,10 +227,6 @@ public class ConfigRefreshComponent { String pdfBookmarkDisable = properties.getProperty("pdf.bookmark.disable", ConfigConstants.DEFAULT_PDF_BOOKMARK_DISABLE); String pdfDisableEditing = properties.getProperty("pdf.disable.editing", ConfigConstants.DEFAULT_PDF_DISABLE_EDITING); int pdf2JpgDpi = Integer.parseInt(properties.getProperty("pdf2jpg.dpi", ConfigConstants.DEFAULT_PDF2_JPG_DPI)); - int pdfTimeout = Integer.parseInt(properties.getProperty("pdf.timeout", ConfigConstants.DEFAULT_PDF_TIMEOUT)); - int pdfTimeout80 = Integer.parseInt(properties.getProperty("pdf.timeout80", ConfigConstants.DEFAULT_PDF_TIMEOUT80)); - int pdfTimeout200 = Integer.parseInt(properties.getProperty("pdf.timeout200", ConfigConstants.DEFAULT_PDF_TIMEOUT200)); - int pdfThread = Integer.parseInt(properties.getProperty("pdf.thread", ConfigConstants.DEFAULT_PDF_THREAD)); // 8. CAD配置 String cadTimeout = properties.getProperty("cad.timeout", ConfigConstants.DEFAULT_CAD_TIMEOUT); @@ -247,25 +239,66 @@ public class ConfigRefreshComponent { boolean deleteSourceFile = Boolean.parseBoolean(properties.getProperty("delete.source.file", ConfigConstants.DEFAULT_DELETE_SOURCE_FILE)); boolean deleteCaptcha = Boolean.parseBoolean(properties.getProperty("delete.captcha", ConfigConstants.DEFAULT_DELETE_CAPTCHA)); - // 10. 首页配置 + // 10. TIF配置 + String tifTimeout = properties.getProperty("tif.timeout", ConfigConstants.DEFAULT_TIF_TIMEOUT); + int tifThread = Integer.parseInt(properties.getProperty("tif.thread", ConfigConstants.DEFAULT_TIF_THREAD)); + + // 11. 首页配置 String beian = properties.getProperty("beian", ConfigConstants.DEFAULT_BEIAN); String homePageNumber = properties.getProperty("home.pagenumber", ConfigConstants.DEFAULT_HOME_PAGENUMBER); String homePagination = properties.getProperty("home.pagination", ConfigConstants.DEFAULT_HOME_PAGINATION); String homePageSize = properties.getProperty("home.pagesize", ConfigConstants.DEFAULT_HOME_PAGSIZE); String homeSearch = properties.getProperty("home.search", ConfigConstants.DEFAULT_HOME_SEARCH); - // 11. 权限配置 + // 12. 权限配置 String key = properties.getProperty("kk.Key", ConfigConstants.DEFAULT_KEY); boolean picturesPreview = Boolean.parseBoolean(properties.getProperty("kk.Picturespreview", ConfigConstants.DEFAULT_PICTURES_PREVIEW)); boolean getCorsFile = Boolean.parseBoolean(properties.getProperty("kk.Getcorsfile", ConfigConstants.DEFAULT_GET_CORS_FILE)); boolean addTask = Boolean.parseBoolean(properties.getProperty("kk.addTask", ConfigConstants.DEFAULT_ADD_TASK)); String aesKey = properties.getProperty("ase.key", ConfigConstants.DEFAULT_AES_KEY); - // 12. UserAgent配置 + + // 13. UserAgent配置 String userAgent = properties.getProperty("useragent", ConfigConstants.DEFAULT_USER_AGENT); - // 13. Basic认证配置 + // 14. Basic认证配置 String basicName = properties.getProperty("basic.name", ConfigConstants.DEFAULT_BASIC_NAME); + // 15. 视频转换配置 + int mediaConvertMaxSize = Integer.parseInt(properties.getProperty("media.convert.max.size", ConfigConstants.DEFAULT_MEDIA_CONVERT_MAX_SIZE)); + boolean mediaTimeoutEnabled = Boolean.parseBoolean(properties.getProperty("media.timeout.enabled", ConfigConstants.DEFAULT_MEDIA_TIMEOUT_ENABLED)); + int mediaSmallFileTimeout = Integer.parseInt(properties.getProperty("media.small.file.timeout", ConfigConstants.DEFAULT_MEDIA_SMALL_FILE_TIMEOUT)); + int mediaMediumFileTimeout = Integer.parseInt(properties.getProperty("media.medium.file.timeout", ConfigConstants.DEFAULT_MEDIA_MEDIUM_FILE_TIMEOUT)); + int mediaLargeFileTimeout = Integer.parseInt(properties.getProperty("media.large.file.timeout", ConfigConstants.DEFAULT_MEDIA_LARGE_FILE_TIMEOUT)); + int mediaXLFileTimeout = Integer.parseInt(properties.getProperty("media.xl.file.timeout", ConfigConstants.DEFAULT_MEDIA_XL_FILE_TIMEOUT)); + int mediaXXLFileTimeout = Integer.parseInt(properties.getProperty("media.xxl.file.timeout", ConfigConstants.DEFAULT_MEDIA_XXL_FILE_TIMEOUT)); + int mediaXXXLFileTimeout = Integer.parseInt(properties.getProperty("media.xxxl.file.timeout", ConfigConstants.DEFAULT_MEDIA_XXXL_FILE_TIMEOUT)); + + // 16. PDF DPI配置 + boolean pdfDpiEnabled = Boolean.parseBoolean(properties.getProperty("pdf.dpi.enabled", ConfigConstants.DEFAULT_PDF_DPI_ENABLED).trim()); + int pdfSmallDpi = Integer.parseInt(properties.getProperty("pdf.dpi.small", ConfigConstants.DEFAULT_PDF_SMALL_DTI).trim()); + int pdfMediumDpi = Integer.parseInt(properties.getProperty("pdf.dpi.medium", ConfigConstants.DEFAULT_PDF_MEDIUM_DPI).trim()); + int pdfLargeDpi = Integer.parseInt(properties.getProperty("pdf.dpi.large", ConfigConstants.DEFAULT_PDF_LARGE_DPI).trim()); + int pdfXLargeDpi = Integer.parseInt(properties.getProperty("pdf.dpi.xlarge", ConfigConstants.DEFAULT_PDF_XLARGE_DPI).trim()); + int pdfXXLargeDpi = Integer.parseInt(properties.getProperty("pdf.dpi.xxlarge", ConfigConstants.DEFAULT_PDF_XXLARGE_DPI).trim()); + + // 17. PDF超时配置(新) + int pdfTimeoutSmall = Integer.parseInt(properties.getProperty("pdf.timeout.small", ConfigConstants.DEFAULT_PDF_TIMEOUT_SMALL).trim()); + int pdfTimeoutMedium = Integer.parseInt(properties.getProperty("pdf.timeout.medium", ConfigConstants.DEFAULT_PDF_TIMEOUT_MEDIUM).trim()); + int pdfTimeoutLarge = Integer.parseInt(properties.getProperty("pdf.timeout.large", ConfigConstants.DEFAULT_PDF_TIMEOUT_LARGE).trim()); + int pdfTimeoutXLarge = Integer.parseInt(properties.getProperty("pdf.timeout.xlarge", ConfigConstants.DEFAULT_PDF_TIMEOUT_XLARGE).trim()); + + // 18. PDF线程配置 + int pdfMaxThreads = Integer.parseInt(properties.getProperty("pdf.max.threads", ConfigConstants.DEFAULT_PDF_MAX_THREADS).trim()); + + // 19. CAD水印配置 + boolean cadwatermark = Boolean.parseBoolean(properties.getProperty("cad.watermark", ConfigConstants.DEFAULT_CAD_WATERMARK)); + + // 20. SSL忽略配置 + boolean ignoreSSL = Boolean.parseBoolean(properties.getProperty("kk.ignore.ssl", ConfigConstants.DEFAULT_IGNORE_SSL)); + + // 21. 重定向启用配置 + boolean enableRedirect = Boolean.parseBoolean(properties.getProperty("kk.enable.redirect", ConfigConstants.DEFAULT_ENABLE_REDIRECT)); + // 设置配置值 // 1. 缓存配置 ConfigConstants.setCacheEnabledValueValue(cacheEnabled); @@ -291,8 +324,6 @@ public class ConfigRefreshComponent { // 4. FTP配置 ConfigConstants.setFtpUsernameValue(ftpUsername); - ConfigConstants.setFtpPasswordValue(ftpPassword); - ConfigConstants.setFtpControlEncodingValue(ftpControlEncoding); // 5. 路径配置 ConfigConstants.setBaseUrlValue(baseUrl); @@ -309,10 +340,6 @@ public class ConfigRefreshComponent { ConfigConstants.setPdfBookmarkDisableValue(pdfBookmarkDisable); ConfigConstants.setPdfDisableEditingValue(pdfDisableEditing); ConfigConstants.setPdf2JpgDpiValue(pdf2JpgDpi); - ConfigConstants.setPdfTimeoutValue(pdfTimeout); - ConfigConstants.setPdfTimeout80Value(pdfTimeout80); - ConfigConstants.setPdfTimeout200Value(pdfTimeout200); - ConfigConstants.setPdfThreadValue(pdfThread); // 8. CAD配置 ConfigConstants.setCadTimeoutValue(cadTimeout); @@ -325,25 +352,65 @@ public class ConfigRefreshComponent { ConfigConstants.setDeleteSourceFileValue(deleteSourceFile); ConfigConstants.setDeleteCaptchaValue(deleteCaptcha); - // 10. 首页配置 + // 10. TIF配置 + ConfigConstants.setTifTimeoutValue(tifTimeout); + ConfigConstants.setTifThreadValue(tifThread); + + // 11. 首页配置 ConfigConstants.setBeianValue(beian); ConfigConstants.setHomePageNumberValue(homePageNumber); ConfigConstants.setHomePaginationValue(homePagination); ConfigConstants.setHomePageSizeValue(homePageSize); ConfigConstants.setHomeSearchValue(homeSearch); - // 11. 权限配置 + // 12. 权限配置 ConfigConstants.setKeyValue(key); ConfigConstants.setPicturesPreviewValue(picturesPreview); ConfigConstants.setGetCorsFileValue(getCorsFile); ConfigConstants.setAddTaskValue(addTask); ConfigConstants.setaesKeyValue(aesKey); - // 12. UserAgent配置 + // 13. UserAgent配置 ConfigConstants.setUserAgentValue(userAgent); - // 13. Basic认证配置 + // 14. Basic认证配置 ConfigConstants.setBasicNameValue(basicName); + + // 15. 视频转换配置 + ConfigConstants.setMediaConvertMaxSizeValue(mediaConvertMaxSize); + ConfigConstants.setMediaTimeoutEnabledValue(mediaTimeoutEnabled); + ConfigConstants.setMediaSmallFileTimeoutValue(mediaSmallFileTimeout); + ConfigConstants.setMediaMediumFileTimeoutValue(mediaMediumFileTimeout); + ConfigConstants.setMediaLargeFileTimeoutValue(mediaLargeFileTimeout); + ConfigConstants.setMediaXLFileTimeoutValue(mediaXLFileTimeout); + ConfigConstants.setMediaXXLFileTimeoutValue(mediaXXLFileTimeout); + ConfigConstants.setMediaXXXLFileTimeoutValue(mediaXXXLFileTimeout); + + // 19. CAD水印配置 + ConfigConstants.setCadwatermarkValue(cadwatermark); + + // 20. SSL忽略配置 + ConfigConstants.setIgnoreSSLValue(ignoreSSL); + + // 21. 重定向启用配置 + ConfigConstants.setEnableRedirectValue(enableRedirect); + + // 16. PDF DPI配置 + ConfigConstants.setPdfDpiEnabledValue(pdfDpiEnabled); + ConfigConstants.setPdfSmallDpiValue(pdfSmallDpi); + ConfigConstants.setPdfMediumDpiValue(pdfMediumDpi); + ConfigConstants.setPdfLargeDpiValue(pdfLargeDpi); + ConfigConstants.setPdfXLargeDpiValue(pdfXLargeDpi); + ConfigConstants.setPdfXXLargeDpiValue(pdfXXLargeDpi); + + // 17. PDF超时配置(新) + ConfigConstants.setPdfTimeoutSmallValue(pdfTimeoutSmall); + ConfigConstants.setPdfTimeoutMediumValue(pdfTimeoutMedium); + ConfigConstants.setPdfTimeoutLargeValue(pdfTimeoutLarge); + ConfigConstants.setPdfTimeoutXLargeValue(pdfTimeoutXLarge); + + // 18. PDF线程配置 + ConfigConstants.setPdfMaxThreadsValue(pdfMaxThreads); } /** diff --git a/server/src/main/java/cn/keking/service/FilePreview.java b/server/src/main/java/cn/keking/service/FilePreview.java index 5a018b4d..a349473a 100644 --- a/server/src/main/java/cn/keking/service/FilePreview.java +++ b/server/src/main/java/cn/keking/service/FilePreview.java @@ -34,6 +34,7 @@ public interface FilePreview { String NOT_SUPPORTED_FILE_PAGE = "fileNotSupported"; String XLSX_FILE_PREVIEW_PAGE = "officeweb"; String CSV_FILE_PREVIEW_PAGE = "csv"; + String WAITING_FILE_PREVIEW_PAGE = "waiting"; String filePreviewHandle(String url, Model model, FileAttribute fileAttribute); } diff --git a/server/src/main/java/cn/keking/service/Mediatomp4Service.java b/server/src/main/java/cn/keking/service/Mediatomp4Service.java new file mode 100644 index 00000000..72e577aa --- /dev/null +++ b/server/src/main/java/cn/keking/service/Mediatomp4Service.java @@ -0,0 +1,621 @@ +package cn.keking.service; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.javacv.FFmpegFrameGrabber; +import org.bytedeco.javacv.FFmpegFrameRecorder; +import org.bytedeco.javacv.Frame; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + + +/** + * 视频转换服务 - JavaCV最佳方案 + * 支持:1) 超时控制 2) 进程监控 3) 资源清理 4) 进度反馈 + */ +@Component +public class Mediatomp4Service { + + private static final Logger logger = LoggerFactory.getLogger(Mediatomp4Service.class); + + private static final String MP4 = "mp4"; + + // 转换任务管理器 + private static final Map activeTasks = new ConcurrentHashMap<>(); + + // 使用带监控的线程池 + private static final ExecutorService videoConversionExecutor = Executors.newVirtualThreadPerTaskExecutor(); + + // 监控线程池 - 用于超时监控 + private static final ScheduledExecutorService monitorExecutor = + Executors.newScheduledThreadPool(2, r -> { + Thread t = new Thread(r, "ffmpeg-monitor-" + System.currentTimeMillis()); + t.setDaemon(true); + return t; + }); + + /** + * 转换任务上下文 + */ + private static class ConversionContext { + final FFmpegFrameGrabber grabber; + final FFmpegFrameRecorder recorder; + final AtomicBoolean cancelled; + final AtomicLong processedFrames; + final ReentrantLock lock; + volatile boolean completed; + + ConversionContext(FFmpegFrameGrabber grabber, FFmpegFrameRecorder recorder) { + this.grabber = grabber; + this.recorder = recorder; + this.cancelled = new AtomicBoolean(false); + this.processedFrames = new AtomicLong(0); + this.lock = new ReentrantLock(); + this.completed = false; + } + } + + /** + * 转换任务包装 + */ + private static class ConversionTask { + final String taskId; + final CompletableFuture future; + final ConversionContext context; + final Thread conversionThread; + volatile ScheduledFuture timeoutFuture; + + ConversionTask(String taskId, CompletableFuture future, + ConversionContext context, Thread conversionThread) { + this.taskId = taskId; + this.future = future; + this.context = context; + this.conversionThread = conversionThread; + } + } + + /** + * 异步转换方法(带任务ID和超时控制) + */ + public static CompletableFuture convertToMp4Async( + String filePath, String outFilePath, FileAttribute fileAttribute) { + + String taskId = generateTaskId(filePath); + CompletableFuture resultFuture = new CompletableFuture<>(); + + // 创建转换线程 + Thread conversionThread = new Thread(() -> { + try { + boolean result = convertToMp4WithCancellation(filePath, outFilePath, + fileAttribute, taskId, resultFuture); + resultFuture.complete(result); + } catch (Exception e) { + resultFuture.completeExceptionally(e); + } finally { + activeTasks.remove(taskId); + } + }, "ffmpeg-convert-" + taskId); + + conversionThread.setDaemon(true); + + // 启动任务 + try { + // 先进行预检查 + preCheckFiles(filePath, outFilePath, fileAttribute); + + // 启动转换线程 + conversionThread.start(); + + // 设置超时监控 + File inputFile = new File(filePath); + long fileSizeMB = inputFile.length() / (1024 * 1024); + scheduleTimeoutMonitor(taskId, calculateTimeout(fileSizeMB)); + return resultFuture; + + } catch (Exception e) { + resultFuture.completeExceptionally(e); + cleanupFailedFile(outFilePath); + return resultFuture; + } + } + + /** + * 带取消支持的同步转换方法(核心改进) + */ + private static boolean convertToMp4WithCancellation( + String filePath, String outFilePath, FileAttribute fileAttribute, + String taskId, CompletableFuture resultFuture) throws Exception { + + FFmpegFrameGrabber frameGrabber = null; + FFmpegFrameRecorder recorder = null; + ConversionContext context = null; + + try { + File sourceFile = new File(filePath); + if (!sourceFile.exists()) { + throw new FileNotFoundException("源文件不存在: " + filePath); + } + + File desFile = new File(outFilePath); + if (desFile.exists() && !fileAttribute.forceUpdatedCache()) { + logger.info("目标文件已存在,跳过转换: {}", outFilePath); + return true; + } + + // 初始化抓取器 + frameGrabber = new FFmpegFrameGrabber(sourceFile); + frameGrabber.setOption("stimeout", "10000000"); // 10秒超时 + frameGrabber.start(); + + // 创建录制器 + recorder = new FFmpegFrameRecorder( + desFile, + frameGrabber.getImageWidth(), + frameGrabber.getImageHeight(), + Math.max(frameGrabber.getAudioChannels(), 0) + ); + + configureRecorder(recorder, frameGrabber); + recorder.start(); + + // 创建任务上下文 + context = new ConversionContext(frameGrabber, recorder); + + // 注册任务 + ConversionTask task = new ConversionTask(taskId, resultFuture, context, + Thread.currentThread()); + activeTasks.put(taskId, task); + + logger.info("开始转换任务 {}: {} -> {}", taskId, filePath, outFilePath); + + // 核心:使用非阻塞方式读取帧 + return processFramesWithTimeout(frameGrabber, recorder, context, taskId); + + } catch (Exception e) { + // 检查是否是取消操作 + if (context != null && context.cancelled.get()) { + logger.info("转换任务 {} 被取消", taskId); + throw new CancellationException("转换被用户取消"); + } + throw e; + } finally { + // 标记完成 + if (context != null) { + context.completed = true; + } + // 清理资源 + closeResources(frameGrabber, recorder); + // 清理失败文件 + if (context != null && context.cancelled.get()) { + cleanupFailedFile(outFilePath); + } + } + } + + /** + * 带超时控制的帧处理(核心改进) + */ + private static boolean processFramesWithTimeout( + FFmpegFrameGrabber grabber, FFmpegFrameRecorder recorder, + ConversionContext context, String taskId) throws Exception { + + long frameCount = 0; + long startTime = System.currentTimeMillis(); + long lastFrameTime = startTime; + int consecutiveNullFrames = 0; // 连续读取到null帧的次数 + final int MAX_CONSECUTIVE_NULL_FRAMES = 10; // 最大连续null帧次数 + + // 设置读取超时 + grabber.setTimeout(5000); // 5秒读取超时 + + try { + Frame frame; + while (!context.cancelled.get()) { + // 检查超时:如果30秒没有新帧,认为超时 + if (System.currentTimeMillis() - lastFrameTime > 30000) { + logger.warn("任务 {} 帧读取超时,可能文件损坏", taskId); + throw new TimeoutException("帧读取超时"); + } + + // 尝试抓取帧 + frame = grabber.grabFrame(); + + if (frame == null) { + consecutiveNullFrames++; + + // 检查是否达到最大连续null帧次数 + if (consecutiveNullFrames >= MAX_CONSECUTIVE_NULL_FRAMES) { + // 检查是否真的结束了 + if (grabber.getLengthInFrames() > 0 && + frameCount >= grabber.getLengthInFrames()) { + logger.debug("任务 {} 正常结束,总帧数: {}", taskId, frameCount); + break; + } else { + logger.warn("任务 {} 连续读取到 {} 个null帧,可能文件已结束或损坏", + taskId, consecutiveNullFrames); + break; + } + } + + // 短暂等待后重试,但避免忙等待 + try { + Thread.sleep(50); // 减少sleep时间 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (context.cancelled.get()) { + throw new CancellationException("转换被取消"); + } + throw e; + } + continue; + } + + // 成功获取到帧,重置计数器 + consecutiveNullFrames = 0; + lastFrameTime = System.currentTimeMillis(); + frameCount++; + + // 记录帧 + recorder.record(frame); + + // 更新上下文 + context.processedFrames.set(frameCount); + + // 定期日志输出 + if (frameCount % 500 == 0) { + long elapsed = System.currentTimeMillis() - startTime; + double fps = (frameCount * 1000.0) / elapsed; + logger.debug("任务 {}: 已处理 {} 帧, 平均速度: {} fps", + taskId, frameCount, String.format("%.2f", fps)); + + // 检查是否被取消 + if (context.cancelled.get()) { + logger.info("任务 {} 在处理中被取消", taskId); + return false; + } + } + + // 检查文件大小增长(防止无限循环) + if (frameCount % 1000 == 0) { + checkProgress(taskId, frameCount, startTime); + } + } + + // 完成录制 + recorder.stop(); + recorder.close(); + + long totalTime = System.currentTimeMillis() - startTime; + double fps = totalTime > 0 ? (frameCount * 1000.0) / totalTime : 0; + logger.info("任务 {} 转换完成: {} 帧, 耗时: {}ms, 平均速度: {} fps", + taskId, frameCount, totalTime, String.format("%.2f", fps)); + + return true; + + } catch (Exception e) { + // 如果是取消操作,不记录为错误 + if (context.cancelled.get()) { + logger.info("任务 {} 在处理中被取消", taskId); + throw new CancellationException("转换被取消"); + } + throw e; + } + } + + /** + * 安全取消指定的转换任务(不中断线程) + */ + public static void cancelConversion(String taskId) { + ConversionTask task = activeTasks.get(taskId); + if (task != null) { + logger.info("安全取消转换任务: {}", taskId); + + // 如果已经完成,则直接移除任务并返回 + if (task.context.completed) { + logger.info("转换任务 {} 已经完成,无需取消", taskId); + activeTasks.remove(taskId); + return; + } + + // 标记为取消(不要中断线程) + task.context.cancelled.set(true); + + // 重要:不要中断线程!让线程自然结束 + // 中断线程可能导致FFmpeg原生代码崩溃 + + // 安全地关闭资源 + safeCloseResources(task.context); + + // 取消超时监控 + if (task.timeoutFuture != null && !task.timeoutFuture.isDone()) { + task.timeoutFuture.cancel(true); + } + + // 从活跃任务中移除 + activeTasks.remove(taskId); + + logger.info("转换任务 {} 已安全取消", taskId); + } + } + + /** + * 安全关闭资源(替代forceCloseResources) + */ + private static void safeCloseResources(ConversionContext context) { + if (context == null) return; + + context.lock.lock(); + try { + // 使用独立的线程进行资源关闭,避免阻塞当前线程 + Thread cleanupThread = new Thread(() -> { + try { + // 给转换线程一点时间响应取消标志 + Thread.sleep(1000); + + // 关闭recorder + if (context.recorder != null) { + try { + if (!context.completed) { + try { + context.recorder.stop(); + } catch (Exception e) { + // 忽略,可能已经停止 + } + } + context.recorder.close(); + } catch (Exception e) { + logger.debug("安全关闭recorder时发生异常", e); + } + } + + // 关闭grabber + if (context.grabber != null) { + try { + context.grabber.stop(); + context.grabber.close(); + } catch (Exception e) { + logger.debug("安全关闭grabber时发生异常", e); + } + } + } catch (Exception e) { + logger.debug("清理线程异常", e); + } + }, "ffmpeg-cleanup-" + context.hashCode()); + + cleanupThread.setDaemon(true); + cleanupThread.start(); + + } finally { + context.lock.unlock(); + } + } + + /** + * 配置超时监控 + */ + private static void scheduleTimeoutMonitor(String taskId, long timeoutSeconds) { + ScheduledFuture timeoutFuture = monitorExecutor.schedule(() -> { + ConversionTask task = activeTasks.get(taskId); + if (task != null && !task.context.completed) { + logger.warn("任务 {} 超时 ({}秒),开始强制终止", taskId, timeoutSeconds); + cancelConversion(taskId); + task.future.completeExceptionally( + new TimeoutException("转换超时: " + timeoutSeconds + "秒") + ); + } + }, timeoutSeconds, TimeUnit.SECONDS); + + // 保存超时future引用 + ConversionTask task = activeTasks.get(taskId); + if (task != null) { + task.timeoutFuture = timeoutFuture; + } + } + + /** + * 计算超时时间(根据文件大小) + */ + public static int calculateTimeout(long fileSizeMB) { + // 如果超时功能被禁用,返回一个非常大的值 + if (!ConfigConstants.isMediaTimeoutEnabled()) { + return Integer.MAX_VALUE; + } + + // 根据文件大小从配置文件读取超时时间 + if (fileSizeMB < 10) return ConfigConstants.getMediaSmallFileTimeout(); // 小文件 + if (fileSizeMB < 50) return ConfigConstants.getMediaMediumFileTimeout(); // 中等文件 + if (fileSizeMB < 200) return ConfigConstants.getMediaLargeFileTimeout(); // 较大文件 + if (fileSizeMB < 500) return ConfigConstants.getMediaXLFileTimeout(); // 大文件 + if (fileSizeMB < 1024) return ConfigConstants.getMediaXXLFileTimeout(); // 超大文件 + return ConfigConstants.getMediaXXXLFileTimeout(); // 极大文件 + } + + /** + * 检查转换进度 + */ + private static void checkProgress(String taskId, long frameCount, long startTime) { + long elapsed = System.currentTimeMillis() - startTime; + if (elapsed > 0) { + double fps = (frameCount * 1000.0) / elapsed; + if (fps < 1.0) { // 速度太慢,可能有问题 + logger.warn("任务 {} 转换速度过慢: {} fps", taskId, String.format("%.2f", fps)); + } + } + } + + /** + * 生成任务ID + */ + private static String generateTaskId(String filePath) { + return "task-" + filePath.hashCode() + "-" + System.currentTimeMillis(); + } + + /** + * 预检查文件 + */ + private static void preCheckFiles(String filePath, String outFilePath, + FileAttribute fileAttribute) throws Exception { + File sourceFile = new File(filePath); + if (!sourceFile.exists()) { + throw new FileNotFoundException("源文件不存在: " + filePath); + } + + File desFile = new File(outFilePath); + if (desFile.exists()) { + if (fileAttribute.forceUpdatedCache()) { + if (!desFile.delete()) { + throw new IOException("无法删除已存在的文件: " + outFilePath); + } + } else { + throw new IllegalStateException("目标文件已存在,跳过转换"); + } + } + } + + /** + * 配置录制器(与原方法保持一致) + */ + private static void configureRecorder(FFmpegFrameRecorder recorder, + FFmpegFrameGrabber grabber) { + recorder.setFormat(MP4); + recorder.setFrameRate(grabber.getFrameRate()); + recorder.setSampleRate(grabber.getSampleRate()); + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + + int videoBitrate = grabber.getVideoBitrate(); + if (videoBitrate <= 0) { + videoBitrate = 2000 * 1000; + } + recorder.setVideoBitrate(videoBitrate); + + if (grabber.getAudioChannels() > 0) { + recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); + recorder.setAudioBitrate(grabber.getAudioBitrate()); + recorder.setAudioChannels(grabber.getAudioChannels()); + } + } + + /** + * 安全关闭资源(带异常保护) + */ + private static void closeResources(FFmpegFrameGrabber grabber, + FFmpegFrameRecorder recorder) { + // 先关闭recorder + if (recorder != null) { + try { + // 尝试停止,但忽略可能的异常 + try { + recorder.stop(); + } catch (Exception e) { + // 忽略,可能已经停止 + } + // 关闭 + recorder.close(); + } catch (Exception e) { + logger.warn("关闭recorder时发生异常(已忽略)", e); + } + } + + // 再关闭grabber + if (grabber != null) { + try { + // 尝试停止,但忽略可能的异常 + try { + grabber.stop(); + } catch (Exception e) { + // 忽略,可能已经停止 + } + // 关闭 + grabber.close(); + } catch (Exception e) { + logger.warn("关闭grabber时发生异常(已忽略)", e); + } + } + } + + /** + * 清理失败的文件 + */ + private static void cleanupFailedFile(String filePath) { + try { + File failedFile = new File(filePath); + if (failedFile.exists() && failedFile.delete()) { + logger.debug("已删除失败的转换文件: {}", filePath); + } + } catch (Exception e) { + logger.warn("无法删除失败的转换文件: {}", filePath, e); + } + } + + /** + * 优雅关闭服务(不强制终止) + */ + public static void shutdown() { + logger.info("开始优雅关闭视频转换服务..."); + + // 标记所有任务为取消,但不强制关闭 + for (String taskId : activeTasks.keySet()) { + ConversionTask task = activeTasks.get(taskId); + if (task != null && !task.context.completed) { + logger.info("标记任务 {} 为取消状态", taskId); + task.context.cancelled.set(true); + } + } + + // 等待所有任务自然结束(最大等待30秒) + long startTime = System.currentTimeMillis(); + while (!activeTasks.isEmpty() && + (System.currentTimeMillis() - startTime) < 30000) { + try { + Thread.sleep(1000); + logger.info("等待 {} 个转换任务自然结束...", activeTasks.size()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // 关闭监控线程池 + if (monitorExecutor != null && !monitorExecutor.isShutdown()) { + try { + monitorExecutor.shutdown(); + if (!monitorExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + logger.info("监控线程池未完全关闭,但将继续关闭流程"); + } + } catch (Exception e) { + logger.warn("关闭监控线程池时发生异常", e); + } + } + + // 关闭转换线程池 + if (videoConversionExecutor != null && !videoConversionExecutor.isShutdown()) { + try { + videoConversionExecutor.shutdown(); + if (!videoConversionExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + logger.info("转换线程池未完全关闭,但将继续关闭流程"); + } + } catch (Exception e) { + logger.warn("关闭转换线程池时发生异常", e); + } + } + + // 最后清理剩余的活动任务 + if (!activeTasks.isEmpty()) { + logger.warn("仍有 {} 个转换任务未完成,将强制移除", activeTasks.size()); + activeTasks.clear(); + } + + logger.info("视频转换服务优雅关闭完成"); + } +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/OfficeToPdfService.java b/server/src/main/java/cn/keking/service/OfficeToPdfService.java index e82e80d7..50df239c 100644 --- a/server/src/main/java/cn/keking/service/OfficeToPdfService.java +++ b/server/src/main/java/cn/keking/service/OfficeToPdfService.java @@ -11,6 +11,8 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.io.File; +import java.time.Duration; +import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -28,6 +30,7 @@ public class OfficeToPdfService { public static void converterFile(File inputFile, String outputFilePath_end, FileAttribute fileAttribute) throws OfficeException { + Instant startTime = Instant.now(); File outputFile = new File(outputFilePath_end); // 假如目标路径不存在,则新建该路径 if (!outputFile.getParentFile().exists() && !outputFile.getParentFile().mkdirs()) { @@ -65,7 +68,42 @@ public class OfficeToPdfService { } else { builder = LocalConverter.builder().storeProperties(customProperties); } - builder.build().convert(inputFile).to(outputFile).execute(); + + try { + builder.build().convert(inputFile).to(outputFile).execute(); + + // 计算转换耗时 + Instant endTime = Instant.now(); + Duration duration = Duration.between(startTime, endTime); + + // 格式化显示耗时(支持不同时间单位) + String durationFormatted; + if (duration.toMinutes() > 0) { + durationFormatted = String.format("%d分%d秒", + duration.toMinutes(), + duration.toSecondsPart()); + } else if (duration.toSeconds() > 0) { + durationFormatted = String.format("%d.%03d秒", + duration.toSeconds(), + duration.toMillisPart()); + } else { + durationFormatted = String.format("%d毫秒", duration.toMillis()); + } + + logger.info("文件转换成功:{} -> {},耗时:{}", + inputFile.getName(), + outputFile.getName(), + durationFormatted); + + } catch (OfficeException e) { + Instant endTime = Instant.now(); + Duration duration = Duration.between(startTime, endTime); + logger.error("文件转换失败:{},已耗时:{}毫秒,错误信息:{}", + inputFile.getName(), + duration.toMillis(), + e.getMessage()); + throw e; + } } @@ -97,4 +135,4 @@ public class OfficeToPdfService { return inputFilePath.substring(inputFilePath.lastIndexOf(".") + 1); } -} +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/PdfToJpgService.java b/server/src/main/java/cn/keking/service/PdfToJpgService.java index f07a3f33..79a57327 100644 --- a/server/src/main/java/cn/keking/service/PdfToJpgService.java +++ b/server/src/main/java/cn/keking/service/PdfToJpgService.java @@ -22,27 +22,27 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.*; -import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; /** - * @author yudian-it + * PDF转JPG服务 - 高性能优化版本 */ @Component public class PdfToJpgService { private final FileHandlerService fileHandlerService; - // PDF转换专用线程池 - private ExecutorService pdfConversionPool; - private ThreadPoolExecutor pdfThreadPoolExecutor; - + // 使用线程池替代虚拟线程,便于控制并发数 + private ExecutorService threadPoolExecutor; private static final Logger logger = LoggerFactory.getLogger(PdfToJpgService.class); private static final String PDF_PASSWORD_MSG = "password"; private static final String PDF2JPG_IMAGE_FORMAT = ".jpg"; + private static final int BATCH_SIZE = 20; + private static final int PARALLEL_BATCH_THRESHOLD = 100; - // 最大并行页数阈值 - private static final int MAX_PARALLEL_PAGES = 20; + // 性能监控 + private final AtomicInteger activeTaskCount = new AtomicInteger(0); + private final AtomicInteger totalCompletedTasks = new AtomicInteger(0); public PdfToJpgService(FileHandlerService fileHandlerService) { this.fileHandlerService = fileHandlerService; @@ -50,119 +50,46 @@ public class PdfToJpgService { @PostConstruct public void init() { - try { - int threadCount = getPdfThreadPoolSize(); - int queueCapacity = threadCount * 10; + // 使用固定大小的线程池,便于控制并发数 + int maxThreads = ConfigConstants.getPdfMaxThreads(); + this.threadPoolExecutor = new ThreadPoolExecutor( + maxThreads, // 核心线程数 + maxThreads, // 最大线程数 + 60L, TimeUnit.SECONDS, // 空闲线程存活时间 + new LinkedBlockingQueue<>(100), // 任务队列 + Executors.defaultThreadFactory(), + new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用线程执行 + ); - AtomicInteger threadNum = new AtomicInteger(1); - pdfThreadPoolExecutor = new ThreadPoolExecutor( - threadCount, - threadCount, - 60L, - TimeUnit.SECONDS, - new LinkedBlockingQueue<>(queueCapacity), - r -> { - Thread t = new Thread(r); - t.setName("pdf-convert-pool-" + threadNum.getAndIncrement()); - t.setUncaughtExceptionHandler((thread, throwable) -> - logger.error("PDF转换线程未捕获异常: {}", thread.getName(), throwable)); - return t; - }, - new ThreadPoolExecutor.CallerRunsPolicy() - ); - - pdfThreadPoolExecutor.allowCoreThreadTimeOut(true); - pdfConversionPool = pdfThreadPoolExecutor; - - logger.info("PDF转换线程池初始化完成,线程数: {}, 队列容量: {}", - threadCount, queueCapacity); - - } catch (Exception e) { - logger.error("PDF转换线程池初始化失败", e); - int defaultThreads = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); - pdfConversionPool = Executors.newFixedThreadPool(defaultThreads); - logger.warn("使用默认PDF线程池配置,线程数: {}", defaultThreads); - } - } - - private int getPdfThreadPoolSize() { - try { - String pdfThreadConfig = System.getProperty("pdf.thread.count"); - int threadCount; - if (pdfThreadConfig != null) { - threadCount = Integer.parseInt(pdfThreadConfig); - } else { - threadCount = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); - } - - if (threadCount <= 0) { - threadCount = Runtime.getRuntime().availableProcessors(); - logger.warn("PDF线程数配置无效,使用CPU核心数: {}", threadCount); - } - - int maxThreads = Runtime.getRuntime().availableProcessors() * 2; - if (threadCount > maxThreads) { - logger.warn("PDF线程数配置过大({}),限制为: {}", threadCount, maxThreads); - threadCount = maxThreads; - } - return threadCount; - } catch (Exception e) { - logger.error("获取PDF线程数配置失败", e); - return Math.max(1, Runtime.getRuntime().availableProcessors() / 2); - } + logger.info("PDF转换线程池初始化完成,最大线程数: {}", maxThreads); } @PreDestroy public void shutdown() { - if (pdfConversionPool != null && !pdfConversionPool.isShutdown()) { - gracefulShutdown(pdfConversionPool, getShutdownTimeout()); - } - } - - private long getShutdownTimeout() { - try { - String pdfTimeout = System.getProperty("pdf.timeout"); - if (pdfTimeout != null) { - return Long.parseLong(pdfTimeout); - } - return Long.parseLong(ConfigConstants.getCadTimeout()); - } catch (Exception e) { - logger.warn("获取PDF关闭超时时间失败,使用默认值60秒", e); - return 60L; - } - } - - private void gracefulShutdown(ExecutorService executor, long timeoutSeconds) { - logger.info("开始关闭{}...", "PDF转换线程池"); - executor.shutdown(); - - try { - if (!executor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - logger.warn("{}超时未关闭,尝试强制关闭...", "PDF转换线程池"); - executor.shutdownNow(); - - if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { - logger.error("{}无法完全关闭", "PDF转换线程池"); - } else { - logger.info("{}已强制关闭", "PDF转换线程池"); + if (threadPoolExecutor != null && !threadPoolExecutor.isShutdown()) { + threadPoolExecutor.shutdown(); + try { + if (!threadPoolExecutor.awaitTermination(60, TimeUnit.SECONDS)) { + threadPoolExecutor.shutdownNow(); } - } else { - logger.info("{}已正常关闭", "PDF转换线程池"); + } catch (InterruptedException e) { + threadPoolExecutor.shutdownNow(); + Thread.currentThread().interrupt(); } - } catch (InterruptedException e) { - logger.error("{}关闭时被中断", "PDF转换线程池", e); - executor.shutdownNow(); - Thread.currentThread().interrupt(); + logger.info("PDF转换服务已关闭"); } } + /** + * PDF转JPG - 高性能主方法 + */ public List pdf2jpg(String fileNameFilePath, String pdfFilePath, String pdfName, FileAttribute fileAttribute) throws Exception { boolean forceUpdatedCache = fileAttribute.forceUpdatedCache(); boolean usePasswordCache = fileAttribute.getUsePasswordCache(); String filePassword = fileAttribute.getFilePassword(); - // 1. 检查缓存 + // 检查缓存 if (!forceUpdatedCache) { List cacheResult = fileHandlerService.loadPdf2jpgCache(pdfFilePath); if (!CollectionUtils.isEmpty(cacheResult)) { @@ -170,14 +97,14 @@ public class PdfToJpgService { } } - // 2. 验证文件存在 + // 验证文件存在 File pdfFile = new File(fileNameFilePath); if (!pdfFile.exists()) { logger.error("PDF文件不存在: {}", fileNameFilePath); return null; } - // 3. 创建输出目录 + // 创建输出目录 int index = pdfFilePath.lastIndexOf("."); String folder = pdfFilePath.substring(0, index); File path = new File(folder); @@ -186,35 +113,282 @@ public class PdfToJpgService { throw new IOException("无法创建输出目录"); } - // 4. 加载PDF文档获取页数 - int pageCount = 0; + // 加载PDF文档获取页数 + int pageCount; try (PDDocument tempDoc = Loader.loadPDF(pdfFile, filePassword)) { pageCount = tempDoc.getNumberOfPages(); - logger.info("PDF文件总页数: {}, 文件: {}", pageCount, pdfFilePath); } catch (IOException e) { handlePdfLoadException(e, pdfFilePath); throw new Exception("PDF文件加载失败", e); } - // 5. 根据页数决定转换策略 + // 检查线程池负载 + checkThreadPoolLoad(); + + // 根据页数选择最佳转换策略 List imageUrls; - if (pageCount > MAX_PARALLEL_PAGES) { - // 大文件使用新方案:每页独立加载PDF - imageUrls = convertParallelIndependent(pdfFile, filePassword, pdfFilePath, folder, pageCount); + long startTime = System.currentTimeMillis(); + + if (pageCount <= PARALLEL_BATCH_THRESHOLD) { + imageUrls = convertOptimizedParallel(pdfFile, filePassword, pdfFilePath, folder, pageCount); } else { - // 小文件使用串行处理(稳定) - imageUrls = convertSequentially(pdfFile, filePassword, pdfFilePath, folder, pageCount); + imageUrls = convertHighPerformance(pdfFile, filePassword, pdfFilePath, folder, pageCount); } - // 6. 缓存结果 + long elapsedTime = System.currentTimeMillis() - startTime; + + // 缓存结果 if (usePasswordCache || ObjectUtils.isEmpty(filePassword)) { fileHandlerService.addPdf2jpgCache(pdfFilePath, pageCount); } - logger.info("PDF转换完成,成功转换{}页,文件: {}", imageUrls.size(), pdfFilePath); + // 性能统计 + logger.info("PDF转换完成: 总页数={}, 耗时={}ms, DPI={}, 文件: {}, 活动任务: {}", + pageCount, elapsedTime, ConfigConstants.getOptimizedDpi(pageCount), + pdfFilePath, activeTaskCount.get()); + return imageUrls; } + /** + * 检查线程池负载 + */ + private void checkThreadPoolLoad() { + if (threadPoolExecutor instanceof ThreadPoolExecutor pool) { + int activeCount = pool.getActiveCount(); + long taskCount = pool.getTaskCount(); + long completedTaskCount = pool.getCompletedTaskCount(); + int queueSize = pool.getQueue().size(); + + logger.debug("线程池状态: 活动线程={}, 队列大小={}, 总任务={}, 已完成={}", + activeCount, queueSize, taskCount, completedTaskCount); + + if (queueSize > 50) { + logger.warn("PDF转换任务队列堆积,当前队列大小: {}", queueSize); + } + } + } + + /** + * 高性能并行转换 - 独立加载每个批次(针对100页以上的大文件) + */ + private List convertHighPerformance(File pdfFile, String filePassword, + String pdfFilePath, String folder, int pageCount) { + List imageUrls = Collections.synchronizedList(new ArrayList<>(pageCount)); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + int batchCount = (pageCount + BATCH_SIZE - 1) / BATCH_SIZE; + long[] totalBatchTime = new long[]{0}; + + logger.info("使用高性能独立加载并行转换,总页数: {}, 批次数: {}, DPI: {}, 超时: {}秒", + pageCount, batchCount, ConfigConstants.getOptimizedDpi(pageCount), + calculateTimeout(pageCount)); + + List> batchFutures = new ArrayList<>(); + + for (int batchIndex = 0; batchIndex < batchCount; batchIndex++) { + final int currentBatch = batchIndex; + final int batchStart = batchIndex * BATCH_SIZE; + final int batchEnd = Math.min(batchStart + BATCH_SIZE, pageCount); + + CompletableFuture batchFuture = CompletableFuture.runAsync(() -> { + activeTaskCount.incrementAndGet(); + long batchStartTime = System.currentTimeMillis(); + try { + try (PDDocument batchDoc = Loader.loadPDF(pdfFile, filePassword)) { + batchDoc.setResourceCache(new NotResourceCache()); + PDFRenderer renderer = new PDFRenderer(batchDoc); + renderer.setSubsamplingAllowed(true); + + // 直接使用配置的DPI值 + int dpi = ConfigConstants.getOptimizedDpi(pageCount); + + int pagesInBatch = 0; + for (int pageIndex = batchStart; pageIndex < batchEnd; pageIndex++) { + try { + String imageFilePath = folder + File.separator + pageIndex + PDF2JPG_IMAGE_FORMAT; + BufferedImage image = renderer.renderImageWithDPI( + pageIndex, + dpi, + ImageType.RGB + ); + + ImageIOUtil.writeImage(image, imageFilePath, dpi); + image.flush(); + + String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex); + synchronized (imageUrls) { + imageUrls.add(imageUrl); + } + + successCount.incrementAndGet(); + pagesInBatch++; + + } catch (Exception e) { + errorCount.incrementAndGet(); + logger.error("转换页 {} 失败: {}", pageIndex, e.getMessage()); + } + } + + long batchTime = System.currentTimeMillis() - batchStartTime; + synchronized (this) { + totalBatchTime[0] += batchTime; + } + + if (logger.isDebugEnabled()) { + logger.debug("批次{}完成: 转换{}页, 耗时: {}ms", + currentBatch, pagesInBatch, batchTime); + } + } + } catch (Exception e) { + logger.error("批次{}处理失败: {}", currentBatch, e.getMessage()); + errorCount.addAndGet(batchEnd - batchStart); + } finally { + activeTaskCount.decrementAndGet(); + totalCompletedTasks.incrementAndGet(); + } + }, threadPoolExecutor); + + batchFutures.add(batchFuture); + } + + // 等待所有批次完成 + int timeout = calculateTimeout(pageCount); + long waitStartTime = System.currentTimeMillis(); + + try { + CompletableFuture allBatches = CompletableFuture.allOf( + batchFutures.toArray(new CompletableFuture[0]) + ); + allBatches.get(timeout, TimeUnit.SECONDS); + } catch (TimeoutException e) { + logger.warn("PDF转换超时,已转换页数: {},超时时间: {}秒", successCount.get(), timeout); + } catch (Exception e) { + logger.error("批量转换失败", e); + } + + long waitTime = System.currentTimeMillis() - waitStartTime; + + logger.info("批次转换统计: 总批次={}, 成功={}, 失败={}, DPI={}, 等待耗时={}ms", + batchCount, successCount.get(), errorCount.get(), + ConfigConstants.getOptimizedDpi(pageCount), waitTime); + + // 按页码排序 + return sortImageUrls(imageUrls); + } + + /** + * 优化并行转换 - 线程安全的批处理模式(针对100页以内的文件) + */ + private List convertOptimizedParallel(File pdfFile, String filePassword, + String pdfFilePath, String folder, int pageCount) { + int dpi = ConfigConstants.getOptimizedDpi(pageCount); + + logger.info("使用高性能批处理并行转换,总页数: {}, DPI: {}, 超时: {}秒", + pageCount, dpi, calculateTimeout(pageCount)); + + // 按CPU核心数划分批次,优化并行度 + int availableProcessors = Runtime.getRuntime().availableProcessors(); + int optimalBatchSize = Math.max(1, pageCount / availableProcessors); + optimalBatchSize = Math.min(optimalBatchSize, 10); // 每批最多10页 + + logger.debug("可用处理器: {}, 推荐批次大小: {}", availableProcessors, optimalBatchSize); + + List>> batchFutures = new ArrayList<>(); + List allImageUrls = Collections.synchronizedList(new ArrayList<>(pageCount)); + + // 分批次并行处理 + for (int batchStart = 0; batchStart < pageCount; batchStart += optimalBatchSize) { + final int startPage = batchStart; + final int endPage = Math.min(batchStart + optimalBatchSize, pageCount); + + CompletableFuture> batchFuture = CompletableFuture.supplyAsync(() -> { + List batchImageUrls = new ArrayList<>(endPage - startPage); + activeTaskCount.incrementAndGet(); + + try { + // 每个批次独立加载PDF,处理一批页面(而不是一页) + try (PDDocument batchDoc = Loader.loadPDF(pdfFile, filePassword)) { + batchDoc.setResourceCache(new NotResourceCache()); + PDFRenderer renderer = new PDFRenderer(batchDoc); + renderer.setSubsamplingAllowed(true); + + for (int pageIndex = startPage; pageIndex < endPage; pageIndex++) { + try { + String imageFilePath = folder + File.separator + pageIndex + PDF2JPG_IMAGE_FORMAT; + BufferedImage image = renderer.renderImageWithDPI( + pageIndex, + dpi, + ImageType.RGB + ); + + ImageIOUtil.writeImage(image, imageFilePath, dpi); + image.flush(); + + String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex); + batchImageUrls.add(imageUrl); + + } catch (Exception e) { + logger.error("批次内转换页 {} 失败: {}", pageIndex, e.getMessage()); + // 添加占位符URL + String placeholderUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex); + batchImageUrls.add(placeholderUrl); + } + } + + if (logger.isDebugEnabled()) { + logger.debug("批次 {}-{} 完成,转换 {} 页", + startPage, endPage - 1, batchImageUrls.size()); + } + } + } catch (Exception e) { + logger.error("批次 {}-{} 加载失败: {}", startPage, endPage - 1, e.getMessage()); + // 为整个批次添加占位符URL + for (int pageIndex = startPage; pageIndex < endPage; pageIndex++) { + batchImageUrls.add(fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex)); + } + } finally { + activeTaskCount.decrementAndGet(); + totalCompletedTasks.incrementAndGet(); + } + + return batchImageUrls; + }, threadPoolExecutor); + + batchFutures.add(batchFuture); + } + + // 等待所有批次完成并收集结果 + CompletableFuture allBatches = CompletableFuture.allOf( + batchFutures.toArray(new CompletableFuture[0]) + ); + + int timeout = calculateTimeout(pageCount); + try { + allBatches.get(timeout, TimeUnit.SECONDS); + + // 收集所有批次的结果 + for (CompletableFuture> future : batchFutures) { + try { + List batchUrls = future.getNow(null); + if (batchUrls != null) { + allImageUrls.addAll(batchUrls); + } + } catch (Exception e) { + // 忽略已完成的任务 + } + } + + } catch (TimeoutException e) { + logger.warn("PDF转换超时,已转换页数: {},超时时间: {}秒", allImageUrls.size(), timeout); + } catch (Exception e) { + logger.error("批次并行转换失败", e); + } + + // 确保返回正确数量的URL + return sortImageUrls(allImageUrls); + } + /** * 处理PDF加载异常 */ @@ -233,167 +407,36 @@ public class PdfToJpgService { } /** - * 串行转换(稳定方案) + * 计算超时时间 - 标准化配置,不使用计算 */ - private List convertSequentially(File pdfFile, String filePassword, - String pdfFilePath, String folder, int pageCount) { - List imageUrls = new ArrayList<>(pageCount); - - try (PDDocument doc = Loader.loadPDF(pdfFile, filePassword)) { - doc.setResourceCache(new NotResourceCache()); - PDFRenderer pdfRenderer = new PDFRenderer(doc); - pdfRenderer.setSubsamplingAllowed(true); - - for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { - try { - String imageFilePath = folder + File.separator + pageIndex + PDF2JPG_IMAGE_FORMAT; - BufferedImage image = pdfRenderer.renderImageWithDPI( - pageIndex, - ConfigConstants.getPdf2JpgDpi(), - ImageType.RGB - ); - - ImageIOUtil.writeImage(image, imageFilePath, ConfigConstants.getPdf2JpgDpi()); - image.flush(); - - String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex); - imageUrls.add(imageUrl); - - logger.debug("串行转换页 {} 完成", pageIndex); - - } catch (Exception e) { - logger.error("串行转换页 {} 失败: {}", pageIndex, e.getMessage()); - } - } - } catch (Exception e) { - logger.error("串行转换PDF失败", e); + private int calculateTimeout(int pageCount) { + // 根据页数范围直接返回对应的超时时间配置 + if (pageCount <= 50) { + return ConfigConstants.getPdfTimeoutSmall(); // 小文件:90秒 + } else if (pageCount <= 200) { + return ConfigConstants.getPdfTimeoutMedium(); // 中等文件:180秒 + } else if (pageCount <= 500) { + return ConfigConstants.getPdfTimeoutLarge(); // 大文件:300秒 + } else { + return ConfigConstants.getPdfTimeoutXLarge(); // 超大文件:600秒 } - - return imageUrls; } /** - * 并行转换 - 每个线程独立加载PDF(避免线程安全问题) + * 按页码排序 */ - private List convertParallelIndependent(File pdfFile, String filePassword, - String pdfFilePath, String folder, int pageCount) { - List imageUrls = Collections.synchronizedList(new ArrayList<>()); - List> futures = new ArrayList<>(); - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger errorCount = new AtomicInteger(0); - - // 提交页面转换任务 - for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { - final int currentPage = pageIndex; - - Future future = pdfConversionPool.submit(() -> { - try { - // 每个任务独立加载PDF,确保线程安全 - try (PDDocument pageDoc = Loader.loadPDF(pdfFile, filePassword)) { - pageDoc.setResourceCache(new NotResourceCache()); - PDFRenderer renderer = new PDFRenderer(pageDoc); - renderer.setSubsamplingAllowed(true); - - String imageFilePath = folder + File.separator + currentPage + PDF2JPG_IMAGE_FORMAT; - BufferedImage image = renderer.renderImageWithDPI( - currentPage, - ConfigConstants.getPdf2JpgDpi(), - ImageType.RGB - ); - - ImageIOUtil.writeImage(image, imageFilePath, ConfigConstants.getPdf2JpgDpi()); - image.flush(); - - String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, currentPage); - synchronized (imageUrls) { - imageUrls.add(imageUrl); - } - - successCount.incrementAndGet(); - logger.debug("并行转换页 {} 完成", currentPage); - return true; - } - } catch (Exception e) { - errorCount.incrementAndGet(); - logger.error("并行转换页 {} 失败: {}", currentPage, e.getMessage()); - return false; - } - }); - - futures.add(future); - } - - // 等待所有任务完成 - int timeout = calculateTimeout(pageCount); - long startTime = System.currentTimeMillis(); - - for (Future future : futures) { + private List sortImageUrls(List imageUrls) { + List sortedImageUrls = new ArrayList<>(imageUrls); + sortedImageUrls.sort((url1, url2) -> { try { - future.get(timeout, TimeUnit.SECONDS); - } catch (TimeoutException e) { - logger.warn("页面转换任务超时,取消剩余任务"); - for (Future f : futures) { - if (!f.isDone()) { - f.cancel(true); - } - } - break; - } catch (Exception e) { - logger.error("页面转换任务执行失败", e); - } - - // 检查是否超时 - if (System.currentTimeMillis() - startTime > timeout * 1000L) { - logger.warn("PDF转换整体超时,取消剩余任务"); - for (Future f : futures) { - if (!f.isDone()) { - f.cancel(true); - } - } - break; - } - } - - long elapsedTime = System.currentTimeMillis() - startTime; - logger.info("并行转换统计: 成功={}, 失败={}, 总页数={}, 耗时={}ms", - successCount.get(), errorCount.get(), pageCount, elapsedTime); - - // 按页码排序 - imageUrls.sort(Comparator.comparingInt(url -> { - try { - String pageStr = url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.')); - return Integer.parseInt(pageStr); + String pageStr1 = url1.substring(url1.lastIndexOf('/') + 1, url1.lastIndexOf('.')); + String pageStr2 = url2.substring(url2.lastIndexOf('/') + 1, url2.lastIndexOf('.')); + return Integer.compare(Integer.parseInt(pageStr1), Integer.parseInt(pageStr2)); } catch (Exception e) { return 0; } - })); - - return imageUrls; + }); + return sortedImageUrls; } - /** - * 计算超时时间 - */ - private int calculateTimeout(int pageCount) { - if (pageCount <= 50) { - return ConfigConstants.getPdfTimeout(); - } else if (pageCount <= 200) { - return ConfigConstants.getPdfTimeout80(); - } else { - return ConfigConstants.getPdfTimeout200(); - } - } - - /** - * 监控线程池状态 - */ - public void monitorThreadPoolStatus() { - if (pdfThreadPoolExecutor != null) { - logger.info("PDF线程池状态: 活跃线程={}, 队列大小={}, 完成任务={}, 线程总数={}", - pdfThreadPoolExecutor.getActiveCount(), - pdfThreadPoolExecutor.getQueue().size(), - pdfThreadPoolExecutor.getCompletedTaskCount(), - pdfThreadPoolExecutor.getPoolSize()); - } - } } \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/TifToPdfService.java b/server/src/main/java/cn/keking/service/TifToPdfService.java new file mode 100644 index 00000000..6ee47a96 --- /dev/null +++ b/server/src/main/java/cn/keking/service/TifToPdfService.java @@ -0,0 +1,472 @@ +package cn.keking.service; + +import cn.keking.config.ConfigConstants; +import cn.keking.utils.WebUtils; +import cn.keking.web.filter.BaseUrlFilter; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.apache.commons.imaging.Imaging; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * TIF文件转换服务 - 虚拟线程版本 (JDK 21+) + */ +@Component +public class TifToPdfService { + + private static final Logger logger = LoggerFactory.getLogger(TifToPdfService.class); + private static final String FILE_DIR = ConfigConstants.getFileDir(); + + // 虚拟线程执行器 + private ExecutorService virtualThreadExecutor; + + @PostConstruct + public void init() { + try { + // 创建虚拟线程执行器 + this.virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + logger.info("TIF转换虚拟线程执行器初始化完成"); + } catch (Exception e) { + logger.error("虚拟线程执行器初始化失败", e); + // 降级为固定线程池 + this.virtualThreadExecutor = Executors.newFixedThreadPool( + Math.min(getMaxConcurrentConversions(), Runtime.getRuntime().availableProcessors() * 2) + ); + logger.warn("使用固定线程池作为降级方案"); + } + } + + /** + * TIF转JPG - 虚拟线程版本 + */ + public List convertTif2Jpg(String strInputFile, String strOutputFile, + boolean forceUpdatedCache) throws Exception { + String fileName = new File(strInputFile).getName(); + Instant startTime = Instant.now(); + + try { + List result = performTifToJpgConversionVirtual( + strInputFile, strOutputFile, forceUpdatedCache + ); + + Duration elapsedTime = Duration.between(startTime, Instant.now()); + boolean success = result != null && !result.isEmpty(); + + logger.info("TIF转换{} - 文件: {}, 耗时: {}ms, 页数: {}", + success ? "成功" : "失败", fileName, elapsedTime.toMillis(), + result != null ? result.size() : 0); + + return result != null ? result : Collections.emptyList(); + + } catch (Exception e) { + logger.error("TIF转JPG失败: {}, 耗时: {}ms", fileName, + Duration.between(startTime, Instant.now()).toMillis(), e); + throw e; + } + } + + /** + * 虚拟线程执行TIF转JPG转换 + */ + private List performTifToJpgConversionVirtual(String strInputFile, String strOutputFile, + boolean forceUpdatedCache) throws Exception { + Instant totalStart = Instant.now(); + + String baseUrl = BaseUrlFilter.getBaseUrl(); + String outputDirPath = strOutputFile.substring(0, strOutputFile.lastIndexOf('.')); + + File tiffFile = new File(strInputFile); + if (!tiffFile.exists()) { + throw new FileNotFoundException("文件不存在: " + strInputFile); + } + + File outputDir = new File(outputDirPath); + if (!outputDir.exists() && !outputDir.mkdirs()) { + throw new IOException("创建目录失败: " + outputDirPath); + } + + // 加载所有图片 + List images; + try { + images = Imaging.getAllBufferedImages(tiffFile); + // logger.info("TIF文件加载完成,共{}页,文件: {}",images.size(), strInputFile); + } catch (IOException e) { + handleImagingException(e, strInputFile); + throw e; + } + + int pageCount = images.size(); + if (pageCount == 0) { + logger.warn("TIF文件没有可转换的页面: {}", strInputFile); + return Collections.emptyList(); + } + + List result = convertPagesVirtualThreads(images, outputDirPath, baseUrl, forceUpdatedCache); + + Duration totalTime = Duration.between(totalStart, Instant.now()); + logger.info("TIF转换PNG完成,总页数: {}, 总耗时: {}ms", pageCount, totalTime.toMillis()); + + return result; + } + + /** + * 使用虚拟线程并行转换页面 + */ + private List convertPagesVirtualThreads(List images, String outputDirPath, + String baseUrl, boolean forceUpdatedCache) { + int pageCount = images.size(); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger skipCount = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + + // 用于收集结果的并发列表 + List imageUrls = Collections.synchronizedList(new ArrayList<>(pageCount)); + + Instant startTime = Instant.now(); + + try { + // 使用虚拟线程并行处理所有页面 + List> futures = IntStream.range(0, pageCount) + .mapToObj(pageIndex -> CompletableFuture.runAsync(() -> { + try { + BufferedImage image = images.get(pageIndex); + + // 使用PNG格式,质量更好 + String fileName = outputDirPath + File.separator + pageIndex + ".png"; + File outputFile = new File(fileName); + + if (forceUpdatedCache || !outputFile.exists()) { + // 创建目录 + File parentDir = outputFile.getParentFile(); + if (!parentDir.exists()) { + parentDir.mkdirs(); + } + + boolean writeSuccess = ImageIO.write(image, "png", outputFile); + if (!writeSuccess) { + throw new IOException("无法写入PNG格式"); + } + successCount.incrementAndGet(); + } else { + skipCount.incrementAndGet(); + } + + // 构建URL + String relativePath = fileName.replace(FILE_DIR, ""); + String url = baseUrl + WebUtils.encodeFileName(relativePath); + imageUrls.add(url); + + } catch (Exception e) { + logger.error("转换页 {} 失败: {}", pageIndex, e.getMessage()); + errorCount.incrementAndGet(); + } + }, virtualThreadExecutor)) + .toList(); + + // 等待所有任务完成 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .completeOnTimeout(null, getConversionTimeout(), TimeUnit.SECONDS) + .join(); + + } catch (Exception e) { + logger.error("虚拟线程并行转换异常", e); + } + + Duration elapsedTime = Duration.between(startTime, Instant.now()); + + logger.info("TIF虚拟线程转换统计: 成功={}, 跳过={}, 失败={}, 总页数={}, 耗时={}ms",successCount.get(), skipCount.get(), errorCount.get(), pageCount, + elapsedTime.toMillis()); + + // 按页码排序 + return imageUrls.stream() + .sorted(Comparator.comparing(url -> { + // 从URL中提取页码进行排序 + String fileName = url.substring(url.lastIndexOf('/') + 1); + return Integer.parseInt(fileName.substring(0, fileName.lastIndexOf('.'))); + })) + .collect(Collectors.toList()); + } + + /** + * TIF转PDF - 虚拟线程版本 + */ + public void convertTif2Pdf(String strJpgFile, String strPdfFile, + boolean forceUpdatedCache) throws Exception { + String fileName = new File(strJpgFile).getName(); + Instant startTime = Instant.now(); + + try { + File pdfFile = new File(strPdfFile); + + // 检查缓存 + if (!forceUpdatedCache && pdfFile.exists()) { + logger.info("PDF文件已存在,跳过转换: {}", strPdfFile); + return; + } + + boolean result = performTifToPdfConversionVirtual(strJpgFile, strPdfFile); + Duration elapsedTime = Duration.between(startTime, Instant.now()); + + logger.info("TIF转PDF{} - 文件: {}, 耗时: {}ms", + result ? "成功" : "失败", fileName, elapsedTime.toMillis()); + + if (!result) { + throw new Exception("TIF转PDF失败"); + } + } catch (Exception e) { + logger.error("TIF转PDF失败: {}, 耗时: {}ms", fileName, + Duration.between(startTime, Instant.now()).toMillis(), e); + throw e; + } + } + + /** + * 虚拟线程执行TIF转PDF转换(保持顺序) + */ + private boolean performTifToPdfConversionVirtual(String strJpgFile, String strPdfFile) throws Exception { + Instant totalStart = Instant.now(); + + File tiffFile = new File(strJpgFile); + + try (PDDocument document = new PDDocument()) { + // 直接使用Imaging获取所有图像 + List images = Imaging.getAllBufferedImages(tiffFile); + + if (images.isEmpty()) { + logger.warn("TIFF文件没有可转换的页面: {}", strJpgFile); + return false; + } + + int pageCount = images.size(); + AtomicInteger processedCount = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + + // 创建页面处理结果的列表 + List> futures = new ArrayList<>(pageCount); + + // 为每个页面创建处理任务 + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + final int currentPageIndex = pageIndex; + BufferedImage originalImage = images.get(pageIndex); + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + // 处理图像(耗时的操作) + BufferedImage processedImage = processImageVirtualOptimized(originalImage); + + // 返回处理结果,包含页码和处理后的图像 + return new ProcessedPageResult(currentPageIndex, processedImage); + } catch (Exception e) { + logger.error("异步处理页 {} 失败", currentPageIndex + 1, e); + errorCount.incrementAndGet(); + return null; + } + }, virtualThreadExecutor); + + futures.add(future); + } + + // 等待所有任务完成 + CompletableFuture allFutures = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + // 设置超时 + allFutures.completeOnTimeout(null, getConversionTimeout(), TimeUnit.SECONDS).join(); + + // 按顺序收集处理结果 + List results = new ArrayList<>(pageCount); + for (CompletableFuture future : futures) { + ProcessedPageResult result = future.get(); + if (result != null) { + results.add(result); + } + } + + // 按页码排序(确保顺序) + results.sort(Comparator.comparingInt(ProcessedPageResult::pageIndex)); + + // 按顺序添加到PDF文档 + for (ProcessedPageResult result : results) { + try { + // 创建页面 + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + // 转换为PDImageXObject + PDImageXObject pdImage = LosslessFactory.createFromImage(document, result.processedImage()); + + // 计算位置并绘制图像 + float[] position = calculateImagePositionOptimized(page, pdImage); + try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) { + contentStream.drawImage(pdImage, position[0], position[1], position[2], position[3]); + } + + // 释放资源 + result.processedImage().flush(); + processedCount.incrementAndGet(); + + } catch (Exception e) { + logger.error("添加页 {} 到PDF失败", result.pageIndex() + 1, e); + errorCount.incrementAndGet(); + } + } + + // 保存PDF + document.save(strPdfFile); + + // Duration totalTime = Duration.between(totalStart, Instant.now()); + //logger.info("PDF异步转换完成: {}, 总页数: {}, 成功: {}, 失败: {}, 总耗时: {}ms", strPdfFile, pageCount, processedCount.get(), errorCount.get(), totalTime.toMillis()); + + return processedCount.get() > 0; + } + } + + /** + * 页面处理结果类 + */ + private record ProcessedPageResult(int pageIndex, BufferedImage processedImage) { + } + + /** + * 优化的图像处理方法 + */ + private BufferedImage processImageVirtualOptimized(BufferedImage original) { + int targetDPI = 150; + float a4WidthInch = 8.27f; + float a4HeightInch = 11.69f; + + int maxWidth = (int) (a4WidthInch * targetDPI); + int maxHeight = (int) (a4HeightInch * targetDPI); + + if (original.getWidth() <= maxWidth && original.getHeight() <= maxHeight) { + return original; + } + + double scaleX = (double) maxWidth / original.getWidth(); + double scaleY = (double) maxHeight / original.getHeight(); + double scale = Math.min(scaleX, scaleY); + + int newWidth = (int) (original.getWidth() * scale); + int newHeight = (int) (original.getHeight() * scale); + + BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = resized.createGraphics(); + + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + + g.drawImage(original, 0, 0, newWidth, newHeight, null); + g.dispose(); + + return resized; + } + + /** + * 优化的位置计算方法 + */ + private float[] calculateImagePositionOptimized(PDPage page, PDImageXObject pdImage) { + float margin = 5; + float pageWidth = page.getMediaBox().getWidth() - 2 * margin; + float pageHeight = page.getMediaBox().getHeight() - 2 * margin; + + float imageWidth = pdImage.getWidth(); + float imageHeight = pdImage.getHeight(); + + float scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight); + float scaledWidth = imageWidth * scale; + float scaledHeight = imageHeight * scale; + + float x = (pageWidth - scaledWidth) / 2 + margin; + float y = (pageHeight - scaledHeight) / 2 + margin; + + return new float[]{x, y, scaledWidth, scaledHeight}; + } + + /** + * 异常处理 + */ + private void handleImagingException(IOException e, String filePath) { + String message = e.getMessage(); + if (message != null && message.contains("Only sequential, baseline JPEGs are supported at the moment")) { + logger.warn("不支持的非基线JPEG格式,文件:{}", filePath, e); + } else { + logger.error("TIF转JPG异常,文件路径:{}", filePath, e); + } + } + + /** + * 获取转换超时时间 + */ + private long getConversionTimeout() { + try { + String timeoutStr = ConfigConstants.getTifTimeout(); + if (timeoutStr != null && !timeoutStr.trim().isEmpty()) { + long timeout = Long.parseLong(timeoutStr); + if (timeout > 0) { + return Math.min(timeout, 600L); + } + } + } catch (NumberFormatException e) { + logger.warn("解析TIF转换超时时间失败,使用默认值300秒", e); + } + return 300L; + } + + /** + * 获取最大并发数 + */ + private int getMaxConcurrentConversions() { + try { + int maxConcurrent = ConfigConstants.getTifThread(); + if (maxConcurrent > 0) { + return Math.min(maxConcurrent, 50); + } + } catch (Exception e) { + logger.error("获取并发数配置失败,使用默认值", e); + } + return 4; + } + + @PreDestroy + public void shutdown() { + logger.info("开始关闭TIF转换服务..."); + + if (virtualThreadExecutor != null && !virtualThreadExecutor.isShutdown()) { + try { + virtualThreadExecutor.shutdown(); + if (!virtualThreadExecutor.awaitTermination(60, TimeUnit.SECONDS)) { + virtualThreadExecutor.shutdownNow(); + } + logger.info("虚拟线程执行器已关闭"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + virtualThreadExecutor.shutdownNow(); + } + } + + logger.info("TIF转换服务已关闭"); + } +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/TifToService.java b/server/src/main/java/cn/keking/service/TifToService.java deleted file mode 100644 index 8327a62f..00000000 --- a/server/src/main/java/cn/keking/service/TifToService.java +++ /dev/null @@ -1,426 +0,0 @@ -package cn.keking.service; - -import cn.keking.config.ConfigConstants; -import cn.keking.utils.WebUtils; -import cn.keking.web.filter.BaseUrlFilter; -import com.itextpdf.text.Document; -import com.itextpdf.text.DocumentException; -import com.itextpdf.text.Image; -import com.itextpdf.text.io.FileChannelRandomAccessSource; -import com.itextpdf.text.pdf.PdfWriter; -import com.itextpdf.text.pdf.RandomAccessFileOrArray; -import com.itextpdf.text.pdf.codec.TiffImage; -import org.apache.commons.imaging.Imaging; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.*; -import java.nio.channels.FileChannel; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; - -@Component -public class TifToService { - - private static final int FIT_WIDTH = 500; - private static final int FIT_HEIGHT = 900; - private static final Logger logger = LoggerFactory.getLogger(TifToService.class); - private static final String FILE_DIR = ConfigConstants.getFileDir(); - // 用于文档同步的锁对象 - private final Object documentLock = new Object(); - // 专用线程池用于TIF转换 - private static ExecutorService tifConversionPool; - - static { - initThreadPool(); - } - - private static void initThreadPool() { - int corePoolSize = getOptimalThreadCount(); - int maxPoolSize = corePoolSize * 2; - long keepAliveTime = 60L; - - tifConversionPool = new ThreadPoolExecutor( - corePoolSize, - maxPoolSize, - keepAliveTime, - TimeUnit.SECONDS, - new LinkedBlockingQueue<>(100), - new ThreadFactory() { - private final AtomicInteger threadNumber = new AtomicInteger(1); - - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r); - t.setName("tif-convert-thread-" + threadNumber.getAndIncrement()); - t.setDaemon(true); - return t; - } - }, - new ThreadPoolExecutor.CallerRunsPolicy() - ); - - logger.info("TIF转换线程池初始化完成,核心线程数: {}, 最大线程数: {}", corePoolSize, maxPoolSize); - } - - private static int getOptimalThreadCount() { - int cpuCores = Runtime.getRuntime().availableProcessors(); - // 对于I/O密集型任务,可以设置更多的线程 - return Math.min(cpuCores * 2, 16); - } - - /** - * 创建 RandomAccessFileOrArray 实例(修复已弃用的构造函数) - */ - private RandomAccessFileOrArray createRandomAccessFileOrArray(File file) throws IOException { - RandomAccessFile aFile = new RandomAccessFile(file, "r"); - FileChannel inChannel = aFile.getChannel(); - FileChannelRandomAccessSource fcra = new FileChannelRandomAccessSource(inChannel); - return new RandomAccessFileOrArray(fcra); - } - - /** - * TIF转JPG - 支持多线程并行处理 - */ - public List convertTif2Jpg(String strInputFile, String strOutputFile, - boolean forceUpdatedCache) throws Exception { - return convertTif2Jpg(strInputFile, strOutputFile, forceUpdatedCache, true); - } - - /** - * TIF转JPG - 可选择是否启用并行处理 - * @param parallelProcessing 是否启用并行处理 - */ - public List convertTif2Jpg(String strInputFile, String strOutputFile, - boolean forceUpdatedCache, boolean parallelProcessing) throws Exception { - String baseUrl = BaseUrlFilter.getBaseUrl(); - String outputDirPath = strOutputFile.substring(0, strOutputFile.lastIndexOf('.')); - - File tiffFile = new File(strInputFile); - if (!tiffFile.exists()) { - logger.error("找不到文件【{}】", strInputFile); - throw new FileNotFoundException("文件不存在: " + strInputFile); - } - - File outputDir = new File(outputDirPath); - if (!outputDir.exists() && !outputDir.mkdirs()) { - throw new IOException("创建目录失败: " + outputDirPath); - } - - // 加载所有图片 - List images; - try { - images = Imaging.getAllBufferedImages(tiffFile); - logger.info("TIF文件加载完成,共{}页,文件: {}", images.size(), strInputFile); - } catch (IOException e) { - handleImagingException(e, strInputFile); - throw e; - } - - int pageCount = images.size(); - - // 根据页面数量决定是否使用并行处理 - boolean useParallel = parallelProcessing && pageCount > 5; - - if (useParallel) { - return convertParallel(images, outputDirPath, baseUrl, forceUpdatedCache); - } else { - return convertSequentially(images, outputDirPath, baseUrl, forceUpdatedCache); - } - } - - /** - * 并行转换 - */ - private List convertParallel(List images, String outputDirPath, - String baseUrl, boolean forceUpdatedCache) { - int pageCount = images.size(); - List> futures = new ArrayList<>(pageCount); - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger skipCount = new AtomicInteger(0); - - long startTime = System.currentTimeMillis(); - - // 提交所有页面转换任务 - for (int i = 0; i < pageCount; i++) { - final int pageIndex = i; - BufferedImage image = images.get(i); - - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - try { - String fileName = outputDirPath + File.separator + pageIndex + ".jpg"; - File outputFile = new File(fileName); - - // 检查是否需要转换 - if (forceUpdatedCache || !outputFile.exists()) { - // 使用PNG格式保持更好的质量,如果需要JPG可以调整 - boolean success = ImageIO.write(image, "png", outputFile); - if (!success) { - logger.error("无法写入图片格式,页号: {}", pageIndex); - return null; - } - logger.debug("并行转换图片页 {} 完成", pageIndex); - successCount.incrementAndGet(); - } else { - logger.debug("使用缓存图片页 {}", pageIndex); - skipCount.incrementAndGet(); - } - - // 构建URL - String relativePath = fileName.replace(FILE_DIR, ""); - return baseUrl + WebUtils.encodeFileName(relativePath); - - } catch (Exception e) { - logger.error("并行转换页 {} 失败: {}", pageIndex, e.getMessage()); - return null; - } - }, tifConversionPool); - - futures.add(future); - } - - // 等待所有任务完成并收集结果 - List imageUrls = futures.stream() - .map(CompletableFuture::join) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - long elapsedTime = System.currentTimeMillis() - startTime; - - logger.info("TIF并行转换完成: 成功={}, 跳过={}, 总页数={}, 耗时={}ms", - successCount.get(), skipCount.get(), pageCount, elapsedTime); - - return imageUrls; - } - - /** - * 串行转换 - */ - private List convertSequentially(List images, String outputDirPath, - String baseUrl, boolean forceUpdatedCache) throws Exception { - List imageUrls = new ArrayList<>(images.size()); - - for (int i = 0; i < images.size(); i++) { - String fileName = outputDirPath + File.separator + i + ".jpg"; - File outputFile = new File(fileName); - - try { - if (forceUpdatedCache || !outputFile.exists()) { - BufferedImage image = images.get(i); - boolean success = ImageIO.write(image, "png", outputFile); - if (!success) { - throw new IOException("无法写入JPG格式图片: " + fileName); - } - logger.debug("转换图片页 {} 完成", i); - } else { - logger.debug("使用缓存图片页 {}", i); - } - - String relativePath = fileName.replace(FILE_DIR, ""); - String url = baseUrl + WebUtils.encodeFileName(relativePath); - imageUrls.add(url); - - } catch (IOException e) { - logger.error("转换页 {} 失败: {}", i, e.getMessage()); - throw e; - } - } - - return imageUrls; - } - - /** - * 将JPG图片转换为PDF - 优化版本 - */ - public void convertTif2Pdf(String strJpgFile, String strPdfFile) throws Exception { - convertJpg2Pdf(strJpgFile, strPdfFile, true); - } - - /** - * 将JPG图片转换为PDF - 支持并行处理图片加载 - */ - public void convertJpg2Pdf(String strJpgFile, String strPdfFile, boolean parallelLoad) throws Exception { - Document document = new Document(); - FileOutputStream outputStream = null; - RandomAccessFileOrArray rafa = null; - - try { - File tiffFile = new File(strJpgFile); - - // 修复:使用非弃用的方式创建 RandomAccessFileOrArray - rafa = createRandomAccessFileOrArray(tiffFile); - int pages = TiffImage.getNumberOfPages(rafa); - logger.info("开始转换TIFF到PDF,总页数: {}", pages); - - outputStream = new FileOutputStream(strPdfFile); - PdfWriter.getInstance(document, outputStream); - document.open(); - - // 修改为传入File对象而不是RandomAccessFileOrArray - if (parallelLoad && pages > 10) { - convertPagesParallel(document, tiffFile, pages); - } else { - convertPagesSequentially(document, tiffFile, pages); - } - - } catch (IOException e) { - handlePdfConversionException(e, strPdfFile); - throw e; - } finally { - // 修复:传入 rafa 以正确关闭资源 - closeResources(document, rafa, outputStream); - } - - logger.info("PDF转换完成: {}", strPdfFile); - } - - /** - * 串行处理页面 - 修复版 - */ - private void convertPagesSequentially(Document document, File tiffFile, int pages) throws IOException, DocumentException { - RandomAccessFileOrArray rafa = null; - try { - // 修复:使用非弃用的方式创建 RandomAccessFileOrArray - rafa = createRandomAccessFileOrArray(tiffFile); - for (int i = 1; i <= pages; i++) { - Image image = TiffImage.getTiffImage(rafa, i); - image.scaleToFit(FIT_WIDTH, FIT_HEIGHT); - document.add(image); - - if (i % 10 == 0) { - logger.debug("已处理 {} 页", i); - } - } - } finally { - if (rafa != null) { - try { - rafa.close(); - } catch (Exception e) { - logger.warn("关闭RandomAccessFileOrArray失败", e); - } - } - } - } - - /** - * 并行加载并添加图片到PDF - 修复版 - */ - private void convertPagesParallel(Document document, File tiffFile, int pages) { - List> futures = new ArrayList<>(); - - // 提交所有页面加载任务 - for (int i = 1; i <= pages; i++) { - final int pageNum = i; - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - RandomAccessFileOrArray localRafa = null; - try { - // 为每个线程创建独立的RandomAccessFileOrArray - // 修复:使用非弃用的方式创建 RandomAccessFileOrArray - localRafa = createRandomAccessFileOrArray(tiffFile); - - Image image = TiffImage.getTiffImage(localRafa, pageNum); - image.scaleToFit(FIT_WIDTH, FIT_HEIGHT); - logger.debug("并行加载TIFF页 {}", pageNum); - return image; - } catch (Exception e) { - logger.error("加载TIFF页 {} 失败", pageNum, e); - return null; - } finally { - if (localRafa != null) { - try { - localRafa.close(); - } catch (Exception e) { - logger.warn("关闭RandomAccessFileOrArray失败", e); - } - } - } - }, tifConversionPool); - - futures.add(future); - } - // 按顺序添加到文档(保持页面顺序) - for (int i = 0; i < futures.size(); i++) { - try { - Image image = futures.get(i).get(30, TimeUnit.SECONDS); - if (image != null) { - // 使用专门的锁对象而不是同步document参数 - synchronized (documentLock) { - document.add(image); - } - } - } catch (Exception e) { - logger.error("添加页 {} 到PDF失败", i + 1, e); - } - } - } - - /** - * 异常处理 - */ - private void handleImagingException(IOException e, String filePath) { - if (!e.getMessage().contains("Only sequential, baseline JPEGs are supported at the moment")) { - logger.error("TIF转JPG异常,文件路径:{}", filePath, e); - } else { - logger.warn("不支持的非基线JPEG格式,文件:{}", filePath); - } - } - - private void handlePdfConversionException(IOException e, String filePath) { - if (!e.getMessage().contains("Bad endianness tag (not 0x4949 or 0x4d4d)")) { - logger.error("TIF转PDF异常,文件路径:{}", filePath, e); - } else { - logger.warn("TIFF文件字节顺序标记错误,文件:{}", filePath); - } - } - - /** - * 资源关闭 - */ - private void closeResources(Document document, RandomAccessFileOrArray rafa, FileOutputStream outputStream) { - try { - if (document != null && document.isOpen()) { - document.close(); - } - } catch (Exception e) { - logger.warn("关闭Document失败", e); - } - - try { - if (rafa != null) { - rafa.close(); - } - } catch (Exception e) { - logger.warn("关闭RandomAccessFileOrArray失败", e); - } - - try { - if (outputStream != null) { - outputStream.close(); - } - } catch (Exception e) { - logger.warn("关闭FileOutputStream失败", e); - } - } - - /** - * 优雅关闭 - */ - public static void shutdown() { - if (tifConversionPool != null && !tifConversionPool.isShutdown()) { - tifConversionPool.shutdown(); - try { - if (!tifConversionPool.awaitTermination(30, TimeUnit.SECONDS)) { - tifConversionPool.shutdownNow(); - } - } catch (InterruptedException e) { - tifConversionPool.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - } -} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/impl/CadFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/CadFilePreviewImpl.java index 36304da1..86d4d917 100644 --- a/server/src/main/java/cn/keking/service/impl/CadFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/CadFilePreviewImpl.java @@ -7,6 +7,7 @@ import cn.keking.service.CadToPdfService; import cn.keking.service.FileHandlerService; import cn.keking.service.FilePreview; import cn.keking.utils.DownloadUtils; +import cn.keking.utils.FileConvertStatusManager; import cn.keking.utils.KkFileUtils; import cn.keking.utils.WebUtils; import cn.keking.web.filter.BaseUrlFilter; @@ -16,6 +17,9 @@ import org.springframework.stereotype.Service; import org.springframework.ui.Model; import org.springframework.util.StringUtils; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * @author chenjh @@ -33,7 +37,13 @@ public class CadFilePreviewImpl implements FilePreview { private final OtherFilePreviewImpl otherFilePreview; private final OfficeFilePreviewImpl officefilepreviewimpl; - public CadFilePreviewImpl(FileHandlerService fileHandlerService, OtherFilePreviewImpl otherFilePreview, CadToPdfService cadtopdfservice,OfficeFilePreviewImpl officefilepreviewimpl ) { + // 用于处理回调的线程池 + private static final ExecutorService callbackExecutor = Executors.newFixedThreadPool(3); + + public CadFilePreviewImpl(FileHandlerService fileHandlerService, + OtherFilePreviewImpl otherFilePreview, + CadToPdfService cadtopdfservice, + OfficeFilePreviewImpl officefilepreviewimpl) { this.fileHandlerService = fileHandlerService; this.otherFilePreview = otherFilePreview; this.cadtopdfservice = cadtopdfservice; @@ -43,41 +53,109 @@ public class CadFilePreviewImpl implements FilePreview { @Override public String filePreviewHandle(String url, Model model, FileAttribute fileAttribute) { // 预览Type,参数传了就取参数的,没传取系统默认 - String officePreviewType = fileAttribute.getOfficePreviewType() == null ? ConfigConstants.getOfficePreviewType() : fileAttribute.getOfficePreviewType(); + String officePreviewType = fileAttribute.getOfficePreviewType() == null ? + ConfigConstants.getOfficePreviewType() : fileAttribute.getOfficePreviewType(); String baseUrl = BaseUrlFilter.getBaseUrl(); boolean forceUpdatedCache = fileAttribute.forceUpdatedCache(); String fileName = fileAttribute.getName(); String cadPreviewType = ConfigConstants.getCadPreviewType(); String cacheName = fileAttribute.getCacheName(); String outFilePath = fileAttribute.getOutFilePath(); + + // 查询转换状态 + FileConvertStatusManager.ConvertStatus status = FileConvertStatusManager.getConvertStatus(fileName); + if (status != null) { + if (status.getStatus() == FileConvertStatusManager.Status.CONVERTING) { + // 正在转换中,返回等待页面 + model.addAttribute("fileName", fileName); + model.addAttribute("message", status.getRealTimeMessage()); + return WAITING_FILE_PREVIEW_PAGE; + } else if (status.getStatus() == FileConvertStatusManager.Status.TIMEOUT) { + // 超时状态,不允许重新转换 + return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换已超时,无法继续转换"); + } else if (status.getStatus() == FileConvertStatusManager.Status.FAILED) { + // 失败状态,不允许重新转换 + return otherFilePreview.notSupportedFile(model, fileAttribute, "文件转换失败,无法继续转换"); + } + } + // 判断之前是否已转换过,如果转换过,直接返回,否则执行转换 - if (forceUpdatedCache || !fileHandlerService.listConvertedFiles().containsKey(cacheName) || !ConfigConstants.isCacheEnabled()) { + if (forceUpdatedCache || !fileHandlerService.listConvertedFiles().containsKey(cacheName) + || !ConfigConstants.isCacheEnabled()) { + + // 检查是否已在转换中 ReturnResponse response = DownloadUtils.downLoad(fileAttribute, fileName); if (response.isFailure()) { return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg()); } + String filePath = response.getContent(); - boolean imageUrls = false; if (StringUtils.hasText(outFilePath)) { try { - imageUrls = cadtopdfservice.cadToPdf(filePath, outFilePath, cadPreviewType, fileAttribute); + // 启动异步转换,并添加回调处理 + startAsyncConversion(filePath, outFilePath, cacheName, fileAttribute); + // 返回等待页面 + model.addAttribute("fileName", fileName); + model.addAttribute("message", "文件正在转换中,请稍候..."); + return WAITING_FILE_PREVIEW_PAGE; } catch (Exception e) { - logger.error("Failed to convert CAD file: {}", filePath, e); - } - if (!imageUrls) { + logger.error("Failed to start CAD conversion: {}", filePath, e); return otherFilePreview.notSupportedFile(model, fileAttribute, "CAD转换异常,请联系管理员"); } - //是否保留CAD源文件 - if (!fileAttribute.isCompressFile() && ConfigConstants.getDeleteSourceFile()) { - KkFileUtils.deleteFileByPath(filePath); - } - if (ConfigConstants.isCacheEnabled()) { - // 加入缓存 - fileHandlerService.addConvertedFile(cacheName, fileHandlerService.getRelativePath(outFilePath)); - } } } - cacheName= WebUtils.encodeFileName(cacheName); + // 如果已有缓存,直接渲染预览 + return renderPreview(model, cacheName, outFilePath, officePreviewType, cadPreviewType, fileAttribute); + } + + /** + * 启动异步转换,并在转换完成后处理后续操作 + */ + private void startAsyncConversion(String filePath, String outFilePath, + String cacheName, FileAttribute fileAttribute) { + // 启动异步转换 + CompletableFuture conversionFuture = cadtopdfservice.cadToPdfAsync( + filePath, + outFilePath, + ConfigConstants.getCadPreviewType(), + fileAttribute + ); + + // 添加转换完成后的回调 + conversionFuture.whenCompleteAsync((success, throwable) -> { + if (success != null && success) { + try { + // 1. 是否保留CAD源文件(只在转换成功后才删除) + if (!fileAttribute.isCompressFile() && ConfigConstants.getDeleteSourceFile()) { + KkFileUtils.deleteFileByPath(filePath); + } + // 2. 加入缓存(只在转换成功后才添加) + if (ConfigConstants.isCacheEnabled()) { + fileHandlerService.addConvertedFile(cacheName, + fileHandlerService.getRelativePath(outFilePath)); + } + } catch (Exception e) { + logger.error("CAD转换后续处理失败: {}", filePath, e); + } + } else { + // 转换失败,保留源文件供排查问题 + logger.error("CAD转换失败,保留源文件: {}", filePath); + if (throwable != null) { + logger.error("转换失败原因: ", throwable); + } + } + }, callbackExecutor); + } + + /** + * 渲染预览页面 + */ + private String renderPreview(Model model, String cacheName, String outFilePath, + String officePreviewType, String cadPreviewType, + FileAttribute fileAttribute) { + cacheName = WebUtils.encodeFileName(cacheName); + String baseUrl = BaseUrlFilter.getBaseUrl(); + if ("tif".equalsIgnoreCase(cadPreviewType)) { model.addAttribute("currentUrl", cacheName); return TIFF_FILE_PREVIEW_PAGE; @@ -85,10 +163,15 @@ public class CadFilePreviewImpl implements FilePreview { model.addAttribute("currentUrl", cacheName); return SVG_FILE_PREVIEW_PAGE; } - if (baseUrl != null && (OFFICE_PREVIEW_TYPE_IMAGE.equals(officePreviewType) || OFFICE_PREVIEW_TYPE_ALL_IMAGES.equals(officePreviewType))) { - return officefilepreviewimpl.getPreviewType(model, fileAttribute, officePreviewType, cacheName, outFilePath, fileHandlerService, OFFICE_PREVIEW_TYPE_IMAGE, otherFilePreview); + + if (baseUrl != null && (OFFICE_PREVIEW_TYPE_IMAGE.equals(officePreviewType) || + OFFICE_PREVIEW_TYPE_ALL_IMAGES.equals(officePreviewType))) { + return officefilepreviewimpl.getPreviewType(model, fileAttribute, officePreviewType, + cacheName, outFilePath, fileHandlerService, + OFFICE_PREVIEW_TYPE_IMAGE, otherFilePreview); } + model.addAttribute("pdfUrl", cacheName); return PDF_FILE_PREVIEW_PAGE; } -} +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/impl/MediaFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/MediaFilePreviewImpl.java index 9409071d..2591e649 100644 --- a/server/src/main/java/cn/keking/service/impl/MediaFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/MediaFilePreviewImpl.java @@ -6,23 +6,19 @@ import cn.keking.model.FileType; import cn.keking.model.ReturnResponse; import cn.keking.service.FileHandlerService; import cn.keking.service.FilePreview; +import cn.keking.service.Mediatomp4Service; import cn.keking.utils.DownloadUtils; -import org.bytedeco.ffmpeg.global.avcodec; -import org.bytedeco.javacv.FFmpegFrameGrabber; -import org.bytedeco.javacv.FFmpegFrameRecorder; -import org.bytedeco.javacv.Frame; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.ui.Model; -import org.springframework.util.CollectionUtils; import java.io.File; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; +import java.io.IOException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; /** * @author : kl @@ -36,14 +32,6 @@ public class MediaFilePreviewImpl implements FilePreview { private static final Logger logger = LoggerFactory.getLogger(MediaFilePreviewImpl.class); private final FileHandlerService fileHandlerService; private final OtherFilePreviewImpl otherFilePreview; - private static final String mp4 = "mp4"; - - // 添加线程池管理视频转换任务 - private static final ExecutorService videoConversionExecutor = - Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); - - // 添加转换任务缓存,避免重复转换 - private static final Map> conversionTasks = new HashMap<>(); public MediaFilePreviewImpl(FileHandlerService fileHandlerService, OtherFilePreviewImpl otherFilePreview) { this.fileHandlerService = fileHandlerService; @@ -61,235 +49,189 @@ public class MediaFilePreviewImpl implements FilePreview { String[] mediaTypesConvert = FileType.MEDIA_CONVERT_TYPES; //获取支持的转换格式 boolean mediaTypes = false; for (String temp : mediaTypesConvert) { - if (suffix.equals(temp)) { + if (suffix.equalsIgnoreCase(temp)) { mediaTypes = true; break; } } - if (!url.toLowerCase().startsWith("http") || checkNeedConvert(mediaTypes)) { //不是http协议的 // 开启转换方式并是支持转换格式的 - if (forceUpdatedCache || !fileHandlerService.listConvertedFiles().containsKey(cacheName) || !ConfigConstants.isCacheEnabled()) { //查询是否开启缓存 - ReturnResponse response = DownloadUtils.downLoad(fileAttribute, fileName); - if (response.isFailure()) { - return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg()); + // 非HTTP协议或需要转换的文件 + if (!url.toLowerCase().startsWith("http") || checkNeedConvert(mediaTypes)) { + // 检查缓存 + File outputFile = new File(outFilePath); + if (outputFile.exists() && !forceUpdatedCache && ConfigConstants.isCacheEnabled()) { + String relativePath = fileHandlerService.getRelativePath(outFilePath); + if (fileHandlerService.listConvertedFiles().containsKey(cacheName)) { + model.addAttribute("mediaUrl", relativePath); + logger.info("使用已缓存的视频文件: {}", cacheName); + return MEDIA_FILE_PREVIEW_PAGE; } - String filePath = response.getContent(); - String convertedUrl = null; - try { - if (mediaTypes) { - // 检查是否已有正在进行的转换任务 - Future conversionTask = conversionTasks.get(cacheName); - if (conversionTask != null && !conversionTask.isDone()) { - // 等待现有转换任务完成 - convertedUrl = conversionTask.get(); - } else { - // 提交新的转换任务 - conversionTask = videoConversionExecutor.submit(() -> { - return convertToMp4(filePath, outFilePath, fileAttribute); - }); - conversionTasks.put(cacheName, conversionTask); - convertedUrl = conversionTask.get(); - } - } else { - convertedUrl = outFilePath; //其他协议的 不需要转换方式的文件 直接输出 - } - } catch (Exception e) { - logger.error("Failed to convert media file: {}", filePath, e); - // 清理失败的任务 - conversionTasks.remove(cacheName); - } - if (convertedUrl == null) { - return otherFilePreview.notSupportedFile(model, fileAttribute, "视频转换异常,请联系管理员"); - } - if (ConfigConstants.isCacheEnabled()) { - // 加入缓存 - fileHandlerService.addConvertedFile(cacheName, fileHandlerService.getRelativePath(outFilePath)); - } - // 转换完成后清理任务缓存 - conversionTasks.remove(cacheName); - model.addAttribute("mediaUrl", fileHandlerService.getRelativePath(outFilePath)); - } else { - model.addAttribute("mediaUrl", fileHandlerService.listConvertedFiles().get(cacheName)); } - return MEDIA_FILE_PREVIEW_PAGE; + + // 下载文件 + ReturnResponse response = DownloadUtils.downLoad(fileAttribute, fileName); + if (response.isFailure()) { + return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg()); + } + + String filePath = response.getContent(); + + try { + if (mediaTypes) { + // 检查文件大小限制 + if (isFileSizeExceeded(filePath)) { + return otherFilePreview.notSupportedFile(model, fileAttribute, + "视频文件大小超过" + ConfigConstants.getMediaConvertMaxSize() + "MB限制,禁止转换"); + } + + // 使用改进的转换方法 + String convertedPath = convertVideoWithImprovedTimeout(filePath, outFilePath, fileAttribute); + if (convertedPath != null) { + model.addAttribute("mediaUrl", fileHandlerService.getRelativePath(convertedPath)); + + // 缓存转换结果 + if (ConfigConstants.isCacheEnabled()) { + fileHandlerService.addConvertedFile(cacheName, fileHandlerService.getRelativePath(convertedPath)); + } + return MEDIA_FILE_PREVIEW_PAGE; + } else { + return otherFilePreview.notSupportedFile(model, fileAttribute, "视频转换失败,请联系管理员"); + } + } else { + // 不需要转换的文件 + model.addAttribute("mediaUrl", fileHandlerService.getRelativePath(outFilePath)); + return MEDIA_FILE_PREVIEW_PAGE; + } + } catch (Exception e) { + logger.error("处理媒体文件失败: {}", filePath, e); + return otherFilePreview.notSupportedFile(model, fileAttribute, + "视频处理异常: " + getErrorMessage(e)); + } } - if (type.equals(FileType.MEDIA)) { // 支持输出 只限默认格式 + + // HTTP协议的媒体文件,直接播放 + if (type.equals(FileType.MEDIA)) { model.addAttribute("mediaUrl", url); return MEDIA_FILE_PREVIEW_PAGE; } + return otherFilePreview.notSupportedFile(model, fileAttribute, "系统还不支持该格式文件的在线预览"); } /** - * 检查视频文件转换是否已开启,以及当前文件是否需要转换 - * - * @return + * 检查文件大小是否超过限制 + */ + private boolean isFileSizeExceeded(String filePath) { + try { + File inputFile = new File(filePath); + if (inputFile.exists()) { + long fileSizeMB = inputFile.length() / (1024 * 1024); + int maxSizeMB = ConfigConstants.getMediaConvertMaxSize(); + + if (fileSizeMB > maxSizeMB) { + logger.warn("视频文件大小超过限制: {}MB > {}MB", fileSizeMB, maxSizeMB); + return true; + } + } + } catch (Exception e) { + logger.error("检查文件大小时出错: {}", filePath, e); + } + return false; + } + + /** + * 改进的转换方法 + */ + private String convertVideoWithImprovedTimeout(String filePath, String outFilePath, + FileAttribute fileAttribute) { + try { + // 检查文件是否存在 + File outputFile = new File(outFilePath); + if (outputFile.exists() && !fileAttribute.forceUpdatedCache()) { + logger.info("输出文件已存在且非强制更新模式,跳过转换!"); + return outFilePath; + } + + // 使用改进的异步转换方法 + CompletableFuture future = + Mediatomp4Service.convertToMp4Async(filePath, outFilePath, fileAttribute); + + // 计算超时时间 + File inputFile = new File(filePath); + long fileSizeMB = inputFile.length() / (1024 * 1024); + int timeoutSeconds = calculateTimeout(fileSizeMB); + + try { + boolean result = future.get(timeoutSeconds, TimeUnit.SECONDS); + if (result) { + // 验证输出文件 + File convertedFile = new File(outFilePath); + if (!convertedFile.exists() || convertedFile.length() == 0) { + throw new IOException("转换完成但输出文件无效"); + } + return outFilePath; + } else { + throw new Exception("转换返回失败状态"); + } + } catch (TimeoutException e) { + // 超时后尝试获取任务ID并取消 + logger.error("视频转换超时: {}, 文件大小: {}MB, 超时: {}秒", + filePath, fileSizeMB, timeoutSeconds); + + throw new RuntimeException("视频转换超时,文件可能过大"); + } + + } catch (Exception e) { + logger.error("视频转换异常: {}", filePath, e); + throw new RuntimeException("视频转换失败: " + getErrorMessage(e), e); + } + } + + /** + * 计算超时时间 - 从配置文件读取 + */ + public int calculateTimeout(long fileSizeMB) { + // 如果超时功能被禁用,返回一个非常大的值 + if (!ConfigConstants.isMediaTimeoutEnabled()) { + return Integer.MAX_VALUE; + } + + // 根据文件大小从配置文件读取超时时间 + if (fileSizeMB < 10) return ConfigConstants.getMediaSmallFileTimeout(); // 小文件 + if (fileSizeMB < 50) return ConfigConstants.getMediaMediumFileTimeout(); // 中等文件 + if (fileSizeMB < 200) return ConfigConstants.getMediaLargeFileTimeout(); // 较大文件 + if (fileSizeMB < 500) return ConfigConstants.getMediaXLFileTimeout(); // 大文件 + if (fileSizeMB < 1024) return ConfigConstants.getMediaXXLFileTimeout(); // 超大文件 + return ConfigConstants.getMediaXXXLFileTimeout(); // 极大文件 + } + + /** + * 检查是否需要转换 */ private boolean checkNeedConvert(boolean mediaTypes) { - //1.检查开关是否开启 + // 1.检查开关是否开启 if ("true".equals(ConfigConstants.getMediaConvertDisable())) { return mediaTypes; } return false; } - private static String convertToMp4(String filePath, String outFilePath, FileAttribute fileAttribute) throws Exception { - FFmpegFrameGrabber frameGrabber = null; - FFmpegFrameRecorder recorder = null; - try { - File desFile = new File(outFilePath); - //判断一下防止重复转换 - if (desFile.exists()) { - return outFilePath; - } - - if (fileAttribute.isCompressFile()) { //判断 是压缩包的创建新的目录 - int index = outFilePath.lastIndexOf("/"); //截取最后一个斜杠的前面的内容 - String folder = outFilePath.substring(0, index); - File path = new File(folder); - //目录不存在 创建新的目录 - if (!path.exists()) { - path.mkdirs(); - } - } - - frameGrabber = FFmpegFrameGrabber.createDefault(filePath); - frameGrabber.start(); - - // 优化:使用更快的编码预设 - recorder = new FFmpegFrameRecorder(outFilePath, - frameGrabber.getImageWidth(), - frameGrabber.getImageHeight(), - frameGrabber.getAudioChannels()); - - // 设置快速编码参数 - recorder.setFormat(mp4); - recorder.setFrameRate(frameGrabber.getFrameRate()); - recorder.setSampleRate(frameGrabber.getSampleRate()); - - // 视频编码属性配置 - 使用快速编码预设 - recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); - recorder.setVideoOption("preset", "veryfast"); // 添加快速编码预设 - recorder.setVideoOption("tune", "zerolatency"); // 降低延迟 - recorder.setVideoBitrate(frameGrabber.getVideoBitrate()); - recorder.setAspectRatio(frameGrabber.getAspectRatio()); - - // 音频编码设置 - recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); - recorder.setAudioBitrate(frameGrabber.getAudioBitrate()); - recorder.setAudioChannels(frameGrabber.getAudioChannels()); - - // 优化:当源文件已经是h264/aac编码时,尝试直接复制流 - if (isCompatibleCodec(frameGrabber)) { - recorder.setVideoOption("c:v", "copy"); - recorder.setAudioOption("c:a", "copy"); - } - - recorder.start(); - - // 批量处理帧,提高处理效率 - Frame capturedFrame; - int batchSize = 100; // 批量处理的帧数 - int frameCount = 0; - Frame[] frameBatch = new Frame[batchSize]; - - while (true) { - capturedFrame = frameGrabber.grabFrame(); - if (capturedFrame == null) { - break; - } - - frameBatch[frameCount % batchSize] = capturedFrame; - frameCount++; - - // 批量记录帧 - if (frameCount % batchSize == 0 || capturedFrame == null) { - for (int i = 0; i < Math.min(batchSize, frameCount); i++) { - if (frameBatch[i] != null) { - recorder.record(frameBatch[i]); - frameBatch[i] = null; // 释放引用 - } - } - } - } - - // 记录剩余的帧 - for (int i = 0; i < frameBatch.length; i++) { - if (frameBatch[i] != null) { - recorder.record(frameBatch[i]); - } - } - - logger.info("视频转码完成: {} -> {}", filePath, outFilePath); - return outFilePath; - - } catch (Exception e) { - logger.error("Failed to convert video file to mp4: {}", filePath, e); - // 删除可能已创建的失败文件 - try { - File failedFile = new File(outFilePath); - if (failedFile.exists()) { - failedFile.delete(); - } - } catch (SecurityException ex) { - logger.warn("无法删除失败的转换文件: {}", outFilePath, ex); - } - throw e; - } finally { - // 确保资源被正确释放 - if (recorder != null) { - try { - recorder.stop(); - recorder.close(); - } catch (Exception e) { - logger.warn("关闭recorder时发生异常", e); - } - } - if (frameGrabber != null) { - try { - frameGrabber.stop(); - frameGrabber.close(); - } catch (Exception e) { - logger.warn("关闭frameGrabber时发生异常", e); - } - } - - // 强制垃圾回收,释放FFmpeg相关资源 - System.gc(); - } - } - /** - * 检查源文件是否已经是兼容的编码格式(H264/AAC) + * 获取友好的错误信息 */ - private static boolean isCompatibleCodec(FFmpegFrameGrabber grabber) { - try { - String videoCodec = grabber.getVideoCodecName(); - String audioCodec = grabber.getAudioCodecName(); - - boolean videoCompatible = videoCodec != null && - (videoCodec.toLowerCase().contains("h264") || - videoCodec.toLowerCase().contains("h.264")); - - boolean audioCompatible = audioCodec != null && - (audioCodec.toLowerCase().contains("aac") || - audioCodec.toLowerCase().contains("mp3")); - - return videoCompatible && audioCompatible; - } catch (Exception e) { - logger.debug("无法获取编解码器信息", e); - return false; + private String getErrorMessage(Exception e) { + if (e instanceof CancellationException) { + return "转换被取消"; + } else if (e instanceof TimeoutException) { + return "转换超时"; + } else if (e.getMessage() != null) { + // 截取主要错误信息 + String msg = e.getMessage(); + if (msg.length() > 100) { + msg = msg.substring(0, 100) + "..."; + } + return msg; } - } - - /** - * 清理所有转换任务(应用关闭时调用) - */ - public static void shutdown() { - if (!CollectionUtils.isEmpty(conversionTasks)) { - conversionTasks.values().forEach(task -> task.cancel(true)); - conversionTasks.clear(); - } - videoConversionExecutor.shutdownNow(); + return "未知错误"; } } \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/impl/TiffFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/TiffFilePreviewImpl.java index d931dd09..66aa5e84 100644 --- a/server/src/main/java/cn/keking/service/impl/TiffFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/TiffFilePreviewImpl.java @@ -5,7 +5,7 @@ import cn.keking.model.FileAttribute; import cn.keking.model.ReturnResponse; import cn.keking.service.FileHandlerService; import cn.keking.service.FilePreview; -import cn.keking.service.TifToService; +import cn.keking.service.TifToPdfService; import cn.keking.utils.DownloadUtils; import cn.keking.utils.KkFileUtils; import cn.keking.utils.WebUtils; @@ -16,7 +16,6 @@ import java.util.List; /** * tiff 图片文件处理 - * * @author kl (http://kailing.pub) * @since 2021/2/8 */ @@ -25,8 +24,8 @@ public class TiffFilePreviewImpl implements FilePreview { private final FileHandlerService fileHandlerService; private final OtherFilePreviewImpl otherFilePreview; - private final TifToService tiftoservice; - public TiffFilePreviewImpl(FileHandlerService fileHandlerService,OtherFilePreviewImpl otherFilePreview,TifToService tiftoservice) { + private final TifToPdfService tiftoservice; + public TiffFilePreviewImpl(FileHandlerService fileHandlerService,OtherFilePreviewImpl otherFilePreview,TifToPdfService tiftoservice) { this.fileHandlerService = fileHandlerService; this.otherFilePreview = otherFilePreview; this.tiftoservice = tiftoservice; @@ -47,7 +46,7 @@ public class TiffFilePreviewImpl implements FilePreview { String filePath = response.getContent(); if ("pdf".equalsIgnoreCase(tifPreviewType)) { try { - tiftoservice.convertTif2Pdf(filePath, outFilePath); + tiftoservice.convertTif2Pdf(filePath, outFilePath,forceUpdatedCache); } catch (Exception e) { if (e.getMessage().contains("Bad endianness tag (not 0x4949 or 0x4d4d)") ) { model.addAttribute("imgUrls", url); @@ -59,7 +58,7 @@ public class TiffFilePreviewImpl implements FilePreview { } //是否保留TIFF源文件 if (!fileAttribute.isCompressFile() && ConfigConstants.getDeleteSourceFile()) { - // KkFileUtils.deleteFileByPath(filePath); + KkFileUtils.deleteFileByPath(filePath); } if (ConfigConstants.isCacheEnabled()) { // 加入缓存 diff --git a/server/src/main/java/cn/keking/utils/DownloadUtils.java b/server/src/main/java/cn/keking/utils/DownloadUtils.java index 391cd609..98ab8723 100644 --- a/server/src/main/java/cn/keking/utils/DownloadUtils.java +++ b/server/src/main/java/cn/keking/utils/DownloadUtils.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory; import io.mola.galimatias.GalimatiasParseException; import org.apache.commons.io.FileUtils; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; @@ -41,7 +42,7 @@ public class DownloadUtils { private static final String URL_PARAM_FTP_CONTROL_ENCODING = "ftp.control.encoding"; private static final String URL_PARAM_FTP_PORT = "ftp.control.port"; private static final RestTemplate restTemplate = new RestTemplate(); - private static final HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); + private static final HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); private static final ObjectMapper mapper = new ObjectMapper(); @@ -51,12 +52,12 @@ public class DownloadUtils { * @return 本地文件绝对路径 */ public static ReturnResponse downLoad(FileAttribute fileAttribute, String fileName) { - // 忽略ssl证书 + String urlStr = null; try { urlStr = fileAttribute.getUrl().replaceAll("\\+", "%20").replaceAll(" ", "%20"); } catch (Exception e) { - logger.error("忽略SSL证书异常:", e); + logger.error("处理URL异常:", e); } ReturnResponse response = new ReturnResponse<>(0, "下载成功!!!", ""); String realPath = getRelFilePath(fileName, fileAttribute); @@ -90,7 +91,10 @@ public class DownloadUtils { if (!fileAttribute.getSkipDownLoad()) { if (isHttpUrl(url)) { File realFile = new File(realPath); - CloseableHttpClient httpClient = SslUtils.createHttpClientIgnoreSsl(); + + // 创建配置好的HttpClient + CloseableHttpClient httpClient = createConfiguredHttpClient(); + factory.setHttpClient(httpClient); restTemplate.setRequestFactory(factory); RequestCallback requestCallback = request -> { @@ -111,10 +115,25 @@ public class DownloadUtils { return null; }); } catch (Exception e) { + // 如果是SSL证书错误,给出建议 + if (e.getMessage() != null && + (e.getMessage().contains("SSL") || + e.getMessage().contains("证书") || + e.getMessage().contains("certificate")) && + !ConfigConstants.isIgnoreSSL()) { + logger.warn("SSL证书验证失败,建议启用SSL忽略功能或检查证书"); + } response.setCode(1); response.setContent(null); response.setMsg("下载失败:" + e); return response; + } finally { + // 确保HttpClient被关闭 + try { + httpClient.close(); + } catch (IOException e) { + logger.warn("关闭HttpClient失败", e); + } } } else if (isFtpUrl(url)) { String ftpUsername = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_USERNAME); @@ -147,6 +166,38 @@ public class DownloadUtils { } } + /** + * 创建根据配置定制的HttpClient + */ + private static CloseableHttpClient createConfiguredHttpClient() throws Exception { + org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom(); + + // 配置SSL + if (ConfigConstants.isIgnoreSSL()) { + logger.debug("创建忽略SSL验证的HttpClient"); + // 如果SslUtils有创建builder的方法就更好了,这里假设我们直接使用SslUtils + // 或者我们可以创建一个新的方法来返回配置了忽略SSL的builder + return createHttpClientWithConfig(); + } else { + logger.debug("创建标准HttpClient"); + } + + // 配置重定向 + if (!ConfigConstants.isEnableRedirect()) { + logger.debug("禁用HttpClient重定向"); + builder.disableRedirectHandling(); + } + + return builder.build(); + } + + /** + * 创建配置了忽略SSL的HttpClient + */ + private static CloseableHttpClient createHttpClientWithConfig() throws Exception { + return SslUtils.createHttpClientIgnoreSsl(); + } + // 处理file协议的文件下载 private static void handleFileProtocol(URL url, String targetPath) throws IOException { @@ -229,4 +280,4 @@ public class DownloadUtils { return realPath; } -} +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/utils/FileConvertStatusManager.java b/server/src/main/java/cn/keking/utils/FileConvertStatusManager.java new file mode 100644 index 00000000..63ececc5 --- /dev/null +++ b/server/src/main/java/cn/keking/utils/FileConvertStatusManager.java @@ -0,0 +1,257 @@ +package cn.keking.utils; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 文件转换状态管理器(增强版) + * 支持实时状态跟踪和状态锁定机制 + */ +public class FileConvertStatusManager { + + // 存储转换状态:key=文件名,value=转换状态对象 + private static final ConcurrentMap STATUS_MAP = new ConcurrentHashMap<>(); + + // 记录最终状态(超时或异常),防止重复转换 + private static final ConcurrentMap FINAL_STATUS_MAP = new ConcurrentHashMap<>(); + + /** + * 开始转换,创建初始状态 + * @param fileName 文件名 + */ + public static void startConvert(String fileName) { + STATUS_MAP.putIfAbsent(fileName, new ConvertState(Status.CONVERTING, "等待转换", 0)); + // 清除可能存在的最终状态,因为要开始新的转换 + FINAL_STATUS_MAP.remove(fileName); + } + + /** + * 更新转换进度 + * @param fileName 文件名 + * @param message 状态消息 + * @param progress 进度百分比(0-100) + */ + public static void updateProgress(String fileName, String message, int progress) { + STATUS_MAP.computeIfPresent(fileName, (key, state) -> { + state.update(message, progress); + logger.debug("更新转换进度: {} -> {} ({}%)", fileName, message, progress); + return state; + }); + } + + /** + * 标记转换超时 - 记录为最终状态 + * @param fileName 文件名 + */ + public static void markTimeout(String fileName) { + STATUS_MAP.put(fileName, new ConvertState(Status.TIMEOUT, "转换超时,请重试", 0)); + // 记录为最终状态 + FINAL_STATUS_MAP.put(fileName, Status.TIMEOUT); + logger.warn("标记文件转换超时: {}", fileName); + } + + /** + * 标记转换失败 - 记录为最终状态 + * @param fileName 文件名 + * @param errorMessage 错误信息 + */ + public static void markError(String fileName, String errorMessage) { + STATUS_MAP.put(fileName, new ConvertState(Status.FAILED, errorMessage, 0)); + // 记录为最终状态 + FINAL_STATUS_MAP.put(fileName, Status.FAILED); + logger.warn("标记文件转换失败: {}, 错误: {}", fileName, errorMessage); + } + + /** + * 查询文件转换状态 + * @param fileName 文件名 + * @return 转换状态对象,如果不存在返回null + */ + public static ConvertStatus getConvertStatus(String fileName) { + // 先检查是否有最终状态 + Status finalStatus = FINAL_STATUS_MAP.get(fileName); + if ((finalStatus == Status.TIMEOUT || finalStatus == Status.FAILED)) { + ConvertState state = STATUS_MAP.get(fileName); + if (state == null) { + // 如果STATUS_MAP中没有,创建一个最终状态 + if (finalStatus == Status.TIMEOUT) { + return new ConvertStatus(Status.TIMEOUT, "转换超时,请重试", 0, 0); + } else { + return new ConvertStatus(Status.FAILED, "转换失败", 0, 0); + } + } + // 返回最终状态 + return new ConvertStatus(state.status, state.message, state.progress, 0); + } + + ConvertState state = STATUS_MAP.get(fileName); + if (state == null) { + return null; + } + + // 如果是转换中状态,计算已等待时间 + long waitingSeconds = 0; + if (state.status == Status.CONVERTING) { + waitingSeconds = (System.currentTimeMillis() - state.startTime) / 1000; + } + + return new ConvertStatus( + state.status, + state.message, + state.progress, + waitingSeconds + ); + } + + /** + * 转换成功 + * @param fileName 文件名 + */ + public static void convertSuccess(String fileName) { + STATUS_MAP.remove(fileName); + // 清除最终状态,允许重新转换 + FINAL_STATUS_MAP.remove(fileName); + } + + /** + * 清理状态(强制重置,允许重新转换) + * @param fileName 文件名 + * @return true: 清理成功; false: 清理失败 + */ + public static boolean clearStatus(String fileName) { + boolean removed1 = STATUS_MAP.remove(fileName) != null; + boolean removed2 = FINAL_STATUS_MAP.remove(fileName) != null; + logger.info("清理文件状态: {}, STATUS_MAP: {}, FINAL_STATUS_MAP: {}", + fileName, removed1, removed2); + return removed1 || removed2; + } + + + + /** + * 清理过期状态(长时间未清理的状态) + * @param expireHours 过期时间(小时) + * @return 清理的数量 + */ + public static int cleanupExpiredStatus(int expireHours) { + long expireMillis = expireHours * 3600 * 1000L; + long currentTime = System.currentTimeMillis(); + + // 清理STATUS_MAP中的过期状态 + int count1 = (int) STATUS_MAP.entrySet().stream() + .filter(entry -> { + ConvertState state = entry.getValue(); + if (state.status == Status.CONVERTING) { + return false; // 转换中的不清理 + } + long elapsed = currentTime - state.startTime; + return elapsed > expireMillis; + }) + .count(); + + // 清理FINAL_STATUS_MAP中的过期状态 + // 注意:FINAL_STATUS_MAP没有时间戳,无法基于时间清理 + // 如果需要清理,可以设置一个独立的过期机制 + + logger.info("清理了 {} 个过期的转换状态", count1); + return count1; + } + + /** + * 转换状态枚举 + */ + public enum Status { + CONVERTING("转换中"), + FAILED("转换失败"), + TIMEOUT("转换超时"), + QUEUED("排队中"); + + private final String description; + + Status(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } + + /** + * 内部状态存储类 + */ + private static class ConvertState { + private final Status status; + private String message; + private int progress; // 0-100 + private final long startTime; + + public ConvertState(Status status, String message, int progress) { + this.status = status; + this.message = message; + this.progress = Math.max(0, Math.min(100, progress)); + this.startTime = System.currentTimeMillis(); + } + + public void update(String message, int progress) { + this.message = message; + this.progress = Math.max(0, Math.min(100, progress)); + } + } + + /** + * 对外暴露的转换状态封装类 + */ + public static class ConvertStatus { + private final Status status; + private final String message; + private final int progress; + private final long waitingSeconds; + private final long timestamp; + + public ConvertStatus(Status status, String message, int progress, long waitingSeconds) { + this.status = status; + this.message = message; + this.progress = progress; + this.waitingSeconds = waitingSeconds; + this.timestamp = System.currentTimeMillis(); + } + + + // 获取实时状态信息 + public String getRealTimeMessage() { + if (status == Status.CONVERTING) { + if (progress > 0) { + return String.format("%s: %s (进度: %d%%,已等待 %d 秒)", + status.getDescription(), message, progress, waitingSeconds); + } else { + return String.format("%s: %s,已等待 %d 秒", + status.getDescription(), message, waitingSeconds); + } + } + return message; + } + + // Getters + public Status getStatus() { return status; } + public String getMessage() { return message; } + public int getProgress() { return progress; } + + public long getTimestamp() { return timestamp; } + + @Override + public String toString() { + return "ConvertStatus{" + + "status=" + status + + ", message='" + message + '\'' + + ", progress=" + progress + + ", waitingSeconds=" + waitingSeconds + + ", timestamp=" + timestamp + + '}'; + } + } + + // 日志记录器 + private static final org.slf4j.Logger logger = + org.slf4j.LoggerFactory.getLogger(FileConvertStatusManager.class); +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/utils/SslUtils.java b/server/src/main/java/cn/keking/utils/SslUtils.java index f28dad6e..824eadc4 100644 --- a/server/src/main/java/cn/keking/utils/SslUtils.java +++ b/server/src/main/java/cn/keking/utils/SslUtils.java @@ -1,8 +1,8 @@ package cn.keking.utils; import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -11,7 +11,8 @@ import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.core5.http.io.SocketConfig; import org.apache.hc.core5.util.Timeout; -import javax.net.ssl.*; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import java.security.cert.X509Certificate; /** @@ -23,40 +24,58 @@ public class SslUtils { * 创建忽略SSL验证的HttpClient(适用于HttpClient 5.6) */ public static CloseableHttpClient createHttpClientIgnoreSsl() throws Exception { - // 创建自定义的SSL上下文 - SSLContext sslContext = createIgnoreVerifySSL(); + return configureHttpClientBuilder(HttpClients.custom(), true, true).build(); + } - // 使用SSLConnectionSocketFactoryBuilder构建SSL连接工厂 - DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy( - sslContext, NoopHostnameVerifier.INSTANCE); + /** + * 配置HttpClientBuilder,支持SSL和重定向配置 + * @param builder HttpClientBuilder + * @param ignoreSSL 是否忽略SSL验证 + * @param enableRedirect 是否启用重定向 + * @return 配置好的HttpClientBuilder + */ + public static HttpClientBuilder configureHttpClientBuilder(HttpClientBuilder builder, + boolean ignoreSSL, + boolean enableRedirect) throws Exception { + // 配置SSL + if (ignoreSSL) { + // 创建自定义的SSL上下文 + SSLContext sslContext = createIgnoreVerifySSL(); - // 使用新的PoolingHttpClientConnectionManagerBuilder构建连接管理器 - // 使用连接管理器构建器 - PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(tlsStrategy) - .setDefaultSocketConfig(SocketConfig.custom() - .setSoTimeout(Timeout.ofSeconds(10)) - .build()) - .build(); + // 使用SSLConnectionSocketFactoryBuilder构建SSL连接工厂 + DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy( + sslContext, NoopHostnameVerifier.INSTANCE); - // 配置连接池参数 - connectionManager.setMaxTotal(200); - connectionManager.setDefaultMaxPerRoute(20); + // 使用连接管理器构建器 + PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tlsStrategy) + .setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(Timeout.ofSeconds(10)) + .build()) + .build(); + + // 配置连接池参数 + connectionManager.setMaxTotal(200); + connectionManager.setDefaultMaxPerRoute(20); + + builder.setConnectionManager(connectionManager); + } // 配置请求参数 RequestConfig requestConfig = RequestConfig.custom() .setConnectionRequestTimeout(Timeout.ofSeconds(10)) .setResponseTimeout(Timeout.ofSeconds(72)) - .setConnectionRequestTimeout(Timeout.ofSeconds(2)) - .setRedirectsEnabled(true) + .setConnectTimeout(Timeout.ofSeconds(2)) + .setRedirectsEnabled(enableRedirect) .setMaxRedirects(5) .build(); + builder.setDefaultRequestConfig(requestConfig); - return HttpClients.custom() - .setConnectionManager(connectionManager) - .setDefaultRequestConfig(requestConfig) - .setRedirectStrategy(DefaultRedirectStrategy.INSTANCE) - .build(); + if (!enableRedirect) { + builder.disableRedirectHandling(); + } + + return builder; } /** @@ -84,7 +103,7 @@ public class SslUtils { } }; - sc.init(null, new TrustManager[]{trustManager}, new java.security.SecureRandom()); + sc.init(null, new javax.net.ssl.TrustManager[]{trustManager}, new java.security.SecureRandom()); return sc; } } \ No newline at end of file diff --git a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java index a295fdf1..6882ba30 100644 --- a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java +++ b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory; import fr.opensagres.xdocreport.core.io.IOUtils; import org.apache.commons.codec.binary.Base64; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; @@ -63,8 +64,6 @@ public class OnlinePreviewController { private final CacheService cacheService; private final FileHandlerService fileHandlerService; private final OtherFilePreviewImpl otherFilePreview; - private static final RestTemplate restTemplate = new RestTemplate(); - private static final HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); private static final ObjectMapper mapper = new ObjectMapper(); public OnlinePreviewController(FilePreviewFactory filePreviewFactory, FileHandlerService fileHandlerService, CacheService cacheService, OtherFilePreviewImpl otherFilePreview) { @@ -181,12 +180,18 @@ public class OnlinePreviewController { InputStream inputStream = null; logger.info("读取跨域pdf文件url:{}", urlPath); if (!isFtpUrl(url)) { - CloseableHttpClient httpClient = SslUtils.createHttpClientIgnoreSsl(); + // 根据配置创建HttpClient + CloseableHttpClient httpClient = createConfiguredHttpClient(); + + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setHttpClient(httpClient); - // restTemplate.setRequestFactory(factory); + + RestTemplate restTemplate = new RestTemplate(); restTemplate.setRequestFactory(factory); + RequestCallback requestCallback = request -> { request.getHeaders().setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL)); + WebUtils.applyBasicAuthHeaders(request.getHeaders(), fileAttribute); String proxyAuthorization = fileAttribute.getKkProxyAuthorization(); if(StringUtils.hasText(proxyAuthorization)){ Map proxyAuthorizationMap = mapper.readValue( @@ -202,9 +207,24 @@ public class OnlinePreviewController { return null; }); } catch (Exception e) { - System.out.println(e); + // 如果是SSL证书错误,给出建议 + if (e.getMessage() != null && + (e.getMessage().contains("SSL") || + e.getMessage().contains("证书") || + e.getMessage().contains("certificate")) && + !ConfigConstants.isIgnoreSSL()) { + logger.warn("SSL证书验证失败,建议启用SSL忽略功能或检查证书"); + } + logger.error("获取跨域文件失败", e); + } finally { + // 确保HttpClient被关闭 + try { + httpClient.close(); + } catch (IOException e) { + logger.warn("关闭HttpClient失败", e); + } } - }else{ + } else { try { String filename = urlPath.substring(urlPath.lastIndexOf('/') + 1); String contentType = WebUtils.getContentTypeByFilename(filename); @@ -225,6 +245,20 @@ public class OnlinePreviewController { } } + /** + * 创建根据配置定制的HttpClient + */ + private CloseableHttpClient createConfiguredHttpClient() throws Exception { + org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom(); + + // 配置SSL和重定向 + return SslUtils.configureHttpClientBuilder( + builder, + ConfigConstants.isIgnoreSSL(), + ConfigConstants.isEnableRedirect() + ).build(); + } + /** * 通过api接口入队 * @@ -260,4 +294,4 @@ public class OnlinePreviewController { cacheService.addQueueTask(fileUrls); return "success"; } -} +} \ No newline at end of file diff --git a/server/src/main/resources/web/waiting.ftl b/server/src/main/resources/web/waiting.ftl new file mode 100644 index 00000000..06ef2be0 --- /dev/null +++ b/server/src/main/resources/web/waiting.ftl @@ -0,0 +1,111 @@ + + + + + + + + ${fileName}文件转换中 + + + + +
+
+

文件转换中

+ +

请稍等,我们正在处理您的文件

+
+ +
+ +
+

正在处理的文件

+ +

${fileName}

+
+ +
+ ${message}... +
+ +
+

页面将在5秒后自动刷新

+
+ +
+ +
+ +
+

提示

+ +
    +
  • 文件转换时间取决于文件大小和服务器负载
  • + +
  • 转换完成后,页面将自动跳转到预览页面
  • + +
  • 您也可以点击"立即刷新"按钮手动检查转换状态
  • +
+
+ + +
+ +