backfill_preview_cache.py 6.3 KB

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