backfill_preview_cache.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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.lower() != "dms_test":
  39. raise RuntimeError("预览缓存回填命令只允许连接dms_test")
  40. # MySQL用SELECT DATABASE();达梦无此函数,改查当前SCHEMA(达梦库名为大写DMS)。
  41. if db.session.get_bind().dialect.name == "dm":
  42. database_name = db.session.scalar(
  43. text("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA')")
  44. )
  45. else:
  46. database_name = db.session.scalar(text("SELECT DATABASE()"))
  47. if (database_name or "").lower() != "dms_test":
  48. raise RuntimeError("预览缓存回填命令只允许连接dms_test")
  49. def backfill(*, dry_run: bool = False, limit: int | None = None) -> dict[str, int]:
  50. _database_gate()
  51. result = {
  52. "processed": 0,
  53. "skipped": 0,
  54. "failed": 0,
  55. "doc_content_backfilled": 0,
  56. }
  57. converter = create_converter(
  58. {
  59. "DMS_OFFICE_PREVIEW_ENABLED": current_app.config.get(
  60. "DMS_OFFICE_PREVIEW_ENABLED", True
  61. ),
  62. "DMS_LIBREOFFICE_EXECUTABLE": current_app.config.get(
  63. "DMS_LIBREOFFICE_EXECUTABLE", ""
  64. ),
  65. "DMS_LIBREOFFICE_TIMEOUT_SECONDS": current_app.config.get(
  66. "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
  67. ),
  68. "DMS_LIBREOFFICE_MAX_CONCURRENCY": current_app.config.get(
  69. "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
  70. ),
  71. }
  72. )
  73. storage_root = Path(
  74. current_app.config.get("DMS_STORAGE_ROOT", "")
  75. ).expanduser().resolve() or (
  76. Path(__file__).resolve().parents[2] / "dms-storage"
  77. )
  78. query = (
  79. select(Document)
  80. .where(
  81. Document.is_deleted.is_(False),
  82. Document.file_extension.in_(["doc", "docx", "pdf"]),
  83. )
  84. .order_by(Document.id.asc())
  85. )
  86. if limit:
  87. query = query.limit(limit)
  88. documents = db.session.scalars(query).all()
  89. for document in documents:
  90. extension = document.file_extension.lower().lstrip(".")
  91. try:
  92. original_path = resolve_storage_path(
  93. document.file_relative_path, storage_root
  94. )
  95. if not original_path.is_file():
  96. logger.warning("文件不存在:document_id=%s", document.id)
  97. result["failed"] += 1
  98. continue
  99. if extension == "pdf":
  100. result["skipped"] += 1
  101. continue
  102. preview_service = PreviewService(storage_root, converter)
  103. # 本任务仅处理 doc/docx(非表格类),single_page 参数不参与键计算
  104. cache_path = preview_cache_path(
  105. preview_cache_key(document.file_hash, extension, False),
  106. storage_root,
  107. )
  108. if preview_service.validate_cached_pdf(cache_path):
  109. result["skipped"] += 1
  110. elif dry_run:
  111. logger.info(
  112. "dry-run:需要生成预览缓存 document_id=%s", document.id
  113. )
  114. result["processed"] += 1
  115. else:
  116. preview_service.preview_path_for(document)
  117. result["processed"] += 1
  118. if extension == "doc" and not dry_run:
  119. if document.content_extract_status in {
  120. ContentExtractStatus.SUCCESS.value,
  121. ContentExtractStatus.EMPTY.value,
  122. }:
  123. pass
  124. else:
  125. text_value, status = extract_doc_content_via_pdf(
  126. original_path, converter, document.file_hash
  127. )
  128. if status != ContentExtractStatus.FAILED.value:
  129. document.content_text = text_value
  130. document.content_extract_status = status
  131. document.content_extracted_at = datetime.now(
  132. timezone.utc
  133. ).replace(tzinfo=None)
  134. document.search_text = build_search_text(
  135. document.document_name,
  136. document.summary,
  137. document.tags,
  138. text_value,
  139. )
  140. db.session.commit()
  141. result["doc_content_backfilled"] += 1
  142. except Exception:
  143. logger.exception("回填预览缓存失败:document_id=%s", document.id)
  144. result["failed"] += 1
  145. db.session.rollback()
  146. return result
  147. def main() -> None:
  148. parser = argparse.ArgumentParser(description="DMS 预览缓存回填命令")
  149. parser.add_argument(
  150. "--dry-run", action="store_true", help="仅输出需要处理的文档,不实际转换"
  151. )
  152. parser.add_argument(
  153. "--limit", type=int, default=None, help="限制处理的文档数量"
  154. )
  155. args = parser.parse_args()
  156. logging.basicConfig(
  157. level=logging.INFO,
  158. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  159. )
  160. app = Flask("dms-preview-backfill")
  161. init_dms(app)
  162. with app.app_context():
  163. outcome = backfill(dry_run=args.dry_run, limit=args.limit)
  164. print(
  165. "预览缓存回填完成:"
  166. f"处理{outcome['processed']}个,跳过{outcome['skipped']}个,"
  167. f"失败{outcome['failed']}个,"
  168. f"DOC正文回填{outcome['doc_content_backfilled']}个。"
  169. )
  170. if __name__ == "__main__":
  171. main()