preview_service.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. # 表格类预览缓存键版本后缀:表格转换策略变更(如启用 SinglePageSheets
  31. # 按内容单页导出)时递增,使旧策略生成的分页缓存自动失效并重新转换。
  32. _SPREADSHEET_CACHE_VERSION = "sps1"
  33. def preview_cache_key(file_hash: str, extension: str) -> str:
  34. """预览缓存键:表格类附加转换策略版本后缀,其余直接使用文件哈希。"""
  35. if extension.lower().lstrip(".") in _SPREADSHEET_EXTENSIONS:
  36. return f"{file_hash}-{_SPREADSHEET_CACHE_VERSION}"
  37. return file_hash
  38. def _get_converter() -> DocumentConverter:
  39. """从 Flask 应用配置获取当前转换器。"""
  40. config = current_app.config
  41. # 应用初始化时若已挂载转换器实例则优先复用,否则动态创建
  42. converter = current_app.extensions.get("dms_document_converter")
  43. if converter is None:
  44. converter = create_converter(config)
  45. current_app.extensions["dms_document_converter"] = converter
  46. return converter
  47. class PreviewService:
  48. """文档预览服务:管理 PDF 直接预览与 Office 转换后预览。"""
  49. def __init__(self, storage_root: Path, converter: DocumentConverter) -> None:
  50. self.storage_root = storage_root
  51. self.converter = converter
  52. @classmethod
  53. def current(cls) -> "PreviewService":
  54. root = Path(current_app.config["DMS_STORAGE_ROOT"]).expanduser().resolve()
  55. return cls(root, _get_converter())
  56. def preview_path_for(self, document) -> Path:
  57. """返回最终用于发送文件的磁盘路径。
  58. - PDF: 原始文件路径
  59. - 图片: 原始文件路径(原图直出,不做转换)
  60. - DOC/DOCX/WPS/XLS/XLSX/ET: 缓存的 PDF 路径(按需转换)
  61. - 其他: 直接抛出 PreviewUnavailableError
  62. """
  63. extension = document.file_extension.lower().lstrip(".")
  64. original = resolve_storage_path(
  65. document.file_relative_path,
  66. self.storage_root,
  67. )
  68. if extension == "pdf" or extension in IMAGE_EXTENSIONS:
  69. return original
  70. if extension not in _CONVERTIBLE_EXTENSIONS:
  71. raise PreviewUnavailableError()
  72. return self._convertible_preview_path(
  73. original, document.file_hash, extension
  74. )
  75. def _convertible_preview_path(
  76. self, source_path: Path, file_hash: str, extension: str
  77. ) -> Path:
  78. if not self.converter.available:
  79. raise PreviewConverterUnavailableError()
  80. cache = preview_cache_path(
  81. preview_cache_key(file_hash, extension), self.storage_root
  82. )
  83. if self.validate_cached_pdf(cache):
  84. return cache
  85. try:
  86. pdf_bytes = self.converter.convert_to_pdf(source_path, file_hash)
  87. except (PreviewConverterUnavailableError, PreviewConversionTimeoutError):
  88. raise
  89. except Exception as exc:
  90. logger.exception("文档转换失败")
  91. raise PreviewConversionFailedError() from exc
  92. if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
  93. raise PreviewConversionFailedError()
  94. # 原子写入:先写临时文件再重命名
  95. cache.parent.mkdir(parents=True, exist_ok=True)
  96. temp_file: Path | None = None
  97. try:
  98. with tempfile.NamedTemporaryFile(
  99. dir=cache.parent,
  100. prefix=f".{file_hash}.",
  101. suffix=".tmp",
  102. delete=False,
  103. ) as temp_stream:
  104. temp_stream.write(pdf_bytes)
  105. temp_file = Path(temp_stream.name)
  106. os.replace(temp_file, cache)
  107. except OSError as exc:
  108. if temp_file is not None:
  109. temp_file.unlink(missing_ok=True)
  110. logger.exception("写入预览缓存失败")
  111. raise PreviewConversionFailedError() from exc
  112. return cache
  113. def validate_cached_pdf(self, cache_path: Path) -> bool:
  114. """验证缓存文件是否为有效 PDF 且位于 preview 目录内。"""
  115. try:
  116. if not is_path_inside_preview(cache_path, self.storage_root):
  117. return False
  118. if not cache_path.is_file() or cache_path.stat().st_size == 0:
  119. return False
  120. header = cache_path.read_bytes()[:8]
  121. if not header.startswith(b"%PDF-"):
  122. return False
  123. with fitz.open(stream=cache_path.read_bytes(), filetype="pdf") as _:
  124. return True
  125. except Exception:
  126. return False
  127. def extract_doc_content_via_pdf(
  128. source_path: Path,
  129. converter: DocumentConverter,
  130. file_hash: str,
  131. ) -> tuple[str | None, str]:
  132. """将 DOC 转换为 PDF 后提取正文。
  133. 返回 (text, status)。status 为 SUCCESS、EMPTY 或 FAILED。
  134. """
  135. from dms.services.document_content_service import _pdf_text
  136. if not converter.available:
  137. return None, ContentExtractStatus.FAILED.value
  138. try:
  139. pdf_bytes = converter.convert_to_pdf(source_path, file_hash)
  140. except Exception:
  141. logger.exception("DOC 转 PDF 提取正文失败")
  142. return None, ContentExtractStatus.FAILED.value
  143. if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
  144. return None, ContentExtractStatus.FAILED.value
  145. temp_path: Path | None = None
  146. try:
  147. with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
  148. temp_file.write(pdf_bytes)
  149. temp_path = Path(temp_file.name)
  150. text = _pdf_text(temp_path)
  151. except Exception:
  152. logger.exception("DOC 转换后的 PDF 提取正文失败")
  153. return None, ContentExtractStatus.FAILED.value
  154. finally:
  155. if temp_path is not None:
  156. temp_path.unlink(missing_ok=True)
  157. return text or None, (
  158. ContentExtractStatus.SUCCESS.value if text
  159. else ContentExtractStatus.EMPTY.value
  160. )
  161. __all__ = [
  162. "PreviewService",
  163. "extract_doc_content_via_pdf",
  164. ]