preview_service.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. """统一预览服务:PDF直接返回;DOC/DOCX按需转换为PDF并缓存。"""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import tempfile
  6. from pathlib import Path
  7. import fitz
  8. from flask import current_app
  9. from dms.common.enums import ContentExtractStatus
  10. from dms.common.errors import (
  11. PreviewConversionFailedError,
  12. PreviewConversionTimeoutError,
  13. PreviewConverterUnavailableError,
  14. PreviewUnavailableError,
  15. )
  16. from dms.services.document_converter import (
  17. DocumentConverter,
  18. _SPREADSHEET_EXTENSIONS,
  19. create_converter,
  20. )
  21. from dms.storage.paths import (
  22. UnsafeStoragePathError,
  23. is_path_inside_preview,
  24. preview_cache_path,
  25. resolve_storage_path,
  26. )
  27. from dms.storage.uploads import IMAGE_EXTENSIONS
  28. logger = logging.getLogger(__name__)
  29. _CONVERTIBLE_EXTENSIONS = {"doc", "docx", "wps", "xls", "xlsx", "et"}
  30. # 表格类预览缓存键按转换策略分键:sps2=SinglePageSheets 按内容区域单页导出,
  31. # sps0=标准纸张分页(旧版 LibreOffice 或探测失败回退)。分键是为防止探测失败
  32. # 期间生成的分页产物占用单页键而永久污染缓存;sps1 为早期不分策略的版本,
  33. # 已被分页产物污染,整体弃用。
  34. _SPREADSHEET_SINGLE_PAGE_CACHE_VERSION = "sps2"
  35. _SPREADSHEET_PAGED_CACHE_VERSION = "sps0"
  36. def preview_cache_key(file_hash: str, extension: str, single_page: bool) -> str:
  37. """预览缓存键:表格类按单页/分页策略附加版本后缀,其余直接使用文件哈希。"""
  38. if extension.lower().lstrip(".") in _SPREADSHEET_EXTENSIONS:
  39. version = (
  40. _SPREADSHEET_SINGLE_PAGE_CACHE_VERSION
  41. if single_page
  42. else _SPREADSHEET_PAGED_CACHE_VERSION
  43. )
  44. return f"{file_hash}-{version}"
  45. return file_hash
  46. def _get_converter() -> DocumentConverter:
  47. """从 Flask 应用配置获取当前转换器。"""
  48. config = current_app.config
  49. # 应用初始化时若已挂载转换器实例则优先复用,否则动态创建
  50. converter = current_app.extensions.get("dms_document_converter")
  51. if converter is None:
  52. converter = create_converter(config)
  53. current_app.extensions["dms_document_converter"] = converter
  54. return converter
  55. class PreviewService:
  56. """文档预览服务:管理 PDF 直接预览与 Office 转换后预览。"""
  57. def __init__(self, storage_root: Path, converter: DocumentConverter) -> None:
  58. self.storage_root = storage_root
  59. self.converter = converter
  60. @classmethod
  61. def current(cls) -> "PreviewService":
  62. root = Path(current_app.config["DMS_STORAGE_ROOT"]).expanduser().resolve()
  63. return cls(root, _get_converter())
  64. def preview_path_for(self, document) -> Path:
  65. """返回最终用于发送文件的磁盘路径。
  66. - PDF: 原始文件路径
  67. - 图片: 原始文件路径(原图直出,不做转换)
  68. - DOC/DOCX/WPS/XLS/XLSX/ET: 缓存的 PDF 路径(按需转换)
  69. - 其他: 直接抛出 PreviewUnavailableError
  70. """
  71. extension = document.file_extension.lower().lstrip(".")
  72. original = resolve_storage_path(
  73. document.file_relative_path,
  74. self.storage_root,
  75. )
  76. if extension == "pdf" or extension in IMAGE_EXTENSIONS:
  77. return original
  78. if extension not in _CONVERTIBLE_EXTENSIONS:
  79. raise PreviewUnavailableError()
  80. return self._convertible_preview_path(
  81. original, document.file_hash, extension
  82. )
  83. def _convertible_preview_path(
  84. self, source_path: Path, file_hash: str, extension: str
  85. ) -> Path:
  86. if not self.converter.available:
  87. raise PreviewConverterUnavailableError()
  88. single_page = (
  89. extension in _SPREADSHEET_EXTENSIONS
  90. and self.converter.supports_single_page_sheets()
  91. )
  92. cache = preview_cache_path(
  93. preview_cache_key(file_hash, extension, single_page),
  94. self.storage_root,
  95. )
  96. if self.validate_cached_pdf(cache):
  97. return cache
  98. try:
  99. pdf_bytes = self.converter.convert_to_pdf(source_path, file_hash)
  100. except (PreviewConverterUnavailableError, PreviewConversionTimeoutError):
  101. raise
  102. except Exception as exc:
  103. logger.exception("文档转换失败")
  104. raise PreviewConversionFailedError() from exc
  105. if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
  106. raise PreviewConversionFailedError()
  107. # 原子写入:先写临时文件再重命名
  108. cache.parent.mkdir(parents=True, exist_ok=True)
  109. temp_file: Path | None = None
  110. try:
  111. with tempfile.NamedTemporaryFile(
  112. dir=cache.parent,
  113. prefix=f".{file_hash}.",
  114. suffix=".tmp",
  115. delete=False,
  116. ) as temp_stream:
  117. temp_stream.write(pdf_bytes)
  118. temp_file = Path(temp_stream.name)
  119. os.replace(temp_file, cache)
  120. except OSError as exc:
  121. if temp_file is not None:
  122. temp_file.unlink(missing_ok=True)
  123. logger.exception("写入预览缓存失败")
  124. raise PreviewConversionFailedError() from exc
  125. return cache
  126. def validate_cached_pdf(self, cache_path: Path) -> bool:
  127. """验证缓存文件是否为有效 PDF 且位于 preview 目录内。"""
  128. try:
  129. if not is_path_inside_preview(cache_path, self.storage_root):
  130. return False
  131. if not cache_path.is_file() or cache_path.stat().st_size == 0:
  132. return False
  133. header = cache_path.read_bytes()[:8]
  134. if not header.startswith(b"%PDF-"):
  135. return False
  136. with fitz.open(stream=cache_path.read_bytes(), filetype="pdf") as _:
  137. return True
  138. except Exception:
  139. return False
  140. def extract_doc_content_via_pdf(
  141. source_path: Path,
  142. converter: DocumentConverter,
  143. file_hash: str,
  144. ) -> tuple[str | None, str]:
  145. """将 DOC 转换为 PDF 后提取正文。
  146. 返回 (text, status)。status 为 SUCCESS、EMPTY 或 FAILED。
  147. """
  148. from dms.services.document_content_service import _pdf_text
  149. if not converter.available:
  150. return None, ContentExtractStatus.FAILED.value
  151. try:
  152. pdf_bytes = converter.convert_to_pdf(source_path, file_hash)
  153. except Exception:
  154. logger.exception("DOC 转 PDF 提取正文失败")
  155. return None, ContentExtractStatus.FAILED.value
  156. if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
  157. return None, ContentExtractStatus.FAILED.value
  158. temp_path: Path | None = None
  159. try:
  160. with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
  161. temp_file.write(pdf_bytes)
  162. temp_path = Path(temp_file.name)
  163. text = _pdf_text(temp_path)
  164. except Exception:
  165. logger.exception("DOC 转换后的 PDF 提取正文失败")
  166. return None, ContentExtractStatus.FAILED.value
  167. finally:
  168. if temp_path is not None:
  169. temp_path.unlink(missing_ok=True)
  170. return text or None, (
  171. ContentExtractStatus.SUCCESS.value if text
  172. else ContentExtractStatus.EMPTY.value
  173. )
  174. __all__ = [
  175. "PreviewService",
  176. "extract_doc_content_via_pdf",
  177. ]