backfill_preview_cache.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. """显式、幂等地为既有 DOC/DOCX/PDF 生成或校验预览缓存。
  2. 用法:
  3. python -m dms.backfill_preview_cache --dry-run
  4. python -m dms.backfill_preview_cache --limit 100
  5. 仅允许显式配置并连接既有 ``dms_test``:
  6. ``python -m dms.backfill_preview_cache``。
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import logging
  11. import os
  12. from pathlib import Path
  13. from datetime import datetime, timezone
  14. from flask import Flask, current_app
  15. from sqlalchemy import select, text
  16. from sqlalchemy.engine import make_url
  17. from dms import init_dms
  18. from dms.common.enums import ContentExtractStatus
  19. from dms.extensions import db
  20. from dms.models import Document
  21. from dms.services.document_converter import create_converter
  22. from dms.services.preview_service import (
  23. PreviewService,
  24. extract_doc_content_via_pdf,
  25. preview_cache_key,
  26. )
  27. from dms.services.document_content_service import build_search_text
  28. from dms.storage.paths import preview_cache_path, resolve_storage_path
  29. logger = logging.getLogger(__name__)
  30. def _database_gate() -> None:
  31. configured = os.environ.get("DMS_DATABASE_URL", "").strip()
  32. if not configured:
  33. raise RuntimeError("必须显式配置DMS_DATABASE_URL并指向dms_test")
  34. try:
  35. configured_database = make_url(configured).database
  36. except Exception as exc:
  37. raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
  38. if configured_database != "dms_test":
  39. raise RuntimeError("预览缓存回填命令只允许连接dms_test")
  40. database_name = db.session.scalar(text("SELECT DATABASE()"))
  41. if database_name != "dms_test":
  42. raise RuntimeError("预览缓存回填命令只允许连接dms_test")
  43. def backfill(*, dry_run: bool = False, limit: int | None = None) -> dict[str, int]:
  44. _database_gate()
  45. result = {
  46. "processed": 0,
  47. "skipped": 0,
  48. "failed": 0,
  49. "doc_content_backfilled": 0,
  50. }
  51. converter = create_converter(
  52. {
  53. "DMS_OFFICE_PREVIEW_ENABLED": current_app.config.get(
  54. "DMS_OFFICE_PREVIEW_ENABLED", True
  55. ),
  56. "DMS_LIBREOFFICE_EXECUTABLE": current_app.config.get(
  57. "DMS_LIBREOFFICE_EXECUTABLE", ""
  58. ),
  59. "DMS_LIBREOFFICE_TIMEOUT_SECONDS": current_app.config.get(
  60. "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
  61. ),
  62. "DMS_LIBREOFFICE_MAX_CONCURRENCY": current_app.config.get(
  63. "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
  64. ),
  65. }
  66. )
  67. storage_root = Path(
  68. current_app.config.get("DMS_STORAGE_ROOT", "")
  69. ).expanduser().resolve() or (
  70. Path(__file__).resolve().parents[2] / "dms-storage"
  71. )
  72. query = (
  73. select(Document)
  74. .where(
  75. Document.is_deleted.is_(False),
  76. Document.file_extension.in_(["doc", "docx", "pdf"]),
  77. )
  78. .order_by(Document.id.asc())
  79. )
  80. if limit:
  81. query = query.limit(limit)
  82. documents = db.session.scalars(query).all()
  83. for document in documents:
  84. extension = document.file_extension.lower().lstrip(".")
  85. try:
  86. original_path = resolve_storage_path(
  87. document.file_relative_path, storage_root
  88. )
  89. if not original_path.is_file():
  90. logger.warning("文件不存在:document_id=%s", document.id)
  91. result["failed"] += 1
  92. continue
  93. if extension == "pdf":
  94. result["skipped"] += 1
  95. continue
  96. preview_service = PreviewService(storage_root, converter)
  97. cache_path = preview_cache_path(
  98. preview_cache_key(document.file_hash, extension), storage_root
  99. )
  100. if preview_service.validate_cached_pdf(cache_path):
  101. result["skipped"] += 1
  102. elif dry_run:
  103. logger.info(
  104. "dry-run:需要生成预览缓存 document_id=%s", document.id
  105. )
  106. result["processed"] += 1
  107. else:
  108. preview_service.preview_path_for(document)
  109. result["processed"] += 1
  110. if extension == "doc" and not dry_run:
  111. if document.content_extract_status in {
  112. ContentExtractStatus.SUCCESS.value,
  113. ContentExtractStatus.EMPTY.value,
  114. }:
  115. pass
  116. else:
  117. text_value, status = extract_doc_content_via_pdf(
  118. original_path, converter, document.file_hash
  119. )
  120. if status != ContentExtractStatus.FAILED.value:
  121. document.content_text = text_value
  122. document.content_extract_status = status
  123. document.content_extracted_at = datetime.now(
  124. timezone.utc
  125. ).replace(tzinfo=None)
  126. document.search_text = build_search_text(
  127. document.document_name,
  128. document.summary,
  129. document.tags,
  130. text_value,
  131. )
  132. db.session.commit()
  133. result["doc_content_backfilled"] += 1
  134. except Exception:
  135. logger.exception("回填预览缓存失败:document_id=%s", document.id)
  136. result["failed"] += 1
  137. db.session.rollback()
  138. return result
  139. def main() -> None:
  140. parser = argparse.ArgumentParser(description="DMS 预览缓存回填命令")
  141. parser.add_argument(
  142. "--dry-run", action="store_true", help="仅输出需要处理的文档,不实际转换"
  143. )
  144. parser.add_argument(
  145. "--limit", type=int, default=None, help="限制处理的文档数量"
  146. )
  147. args = parser.parse_args()
  148. logging.basicConfig(
  149. level=logging.INFO,
  150. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  151. )
  152. app = Flask("dms-preview-backfill")
  153. init_dms(app)
  154. with app.app_context():
  155. outcome = backfill(dry_run=args.dry_run, limit=args.limit)
  156. print(
  157. "预览缓存回填完成:"
  158. f"处理{outcome['processed']}个,跳过{outcome['skipped']}个,"
  159. f"失败{outcome['failed']}个,"
  160. f"DOC正文回填{outcome['doc_content_backfilled']}个。"
  161. )
  162. if __name__ == "__main__":
  163. main()