preview_service.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. # Windows 并发场景:两个请求同时预览同一新文档时,先完成方已写好缓存
  124. # 并开始读取,os.replace 因目标被占用抛 PermissionError——此时缓存本身
  125. # 已有效,幂等复用而非报错。
  126. if self.validate_cached_pdf(cache):
  127. return cache
  128. logger.exception("写入预览缓存失败")
  129. raise PreviewConversionFailedError() from exc
  130. return cache
  131. def validate_cached_pdf(self, cache_path: Path) -> bool:
  132. """验证缓存文件是否为有效 PDF 且位于 preview 目录内。"""
  133. try:
  134. if not is_path_inside_preview(cache_path, self.storage_root):
  135. return False
  136. if not cache_path.is_file() or cache_path.stat().st_size == 0:
  137. return False
  138. header = cache_path.read_bytes()[:8]
  139. if not header.startswith(b"%PDF-"):
  140. return False
  141. with fitz.open(stream=cache_path.read_bytes(), filetype="pdf") as _:
  142. return True
  143. except Exception:
  144. return False
  145. def extract_doc_content_via_pdf(
  146. source_path: Path,
  147. converter: DocumentConverter,
  148. file_hash: str,
  149. ) -> tuple[str | None, str]:
  150. """将 DOC 转换为 PDF 后提取正文。
  151. 返回 (text, status)。status 为 SUCCESS、EMPTY 或 FAILED。
  152. """
  153. from dms.services.document_content_service import _pdf_text
  154. if not converter.available:
  155. return None, ContentExtractStatus.FAILED.value
  156. try:
  157. pdf_bytes = converter.convert_to_pdf(source_path, file_hash)
  158. except Exception:
  159. logger.exception("DOC 转 PDF 提取正文失败")
  160. return None, ContentExtractStatus.FAILED.value
  161. if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
  162. return None, ContentExtractStatus.FAILED.value
  163. temp_path: Path | None = None
  164. try:
  165. with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
  166. temp_file.write(pdf_bytes)
  167. temp_path = Path(temp_file.name)
  168. text = _pdf_text(temp_path)
  169. except Exception:
  170. logger.exception("DOC 转换后的 PDF 提取正文失败")
  171. return None, ContentExtractStatus.FAILED.value
  172. finally:
  173. if temp_path is not None:
  174. temp_path.unlink(missing_ok=True)
  175. return text or None, (
  176. ContentExtractStatus.SUCCESS.value if text
  177. else ContentExtractStatus.EMPTY.value
  178. )
  179. __all__ = [
  180. "PreviewService",
  181. "extract_doc_content_via_pdf",
  182. ]