preview_service.py 5.9 KB

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