Эх сурвалжийг харах

1. 实现了DOC与DOCX转PDF预览,支持LibreOffice离线转换;

2. 新增了预览缓存与回填工具,按文件哈希复用转换结果;

3. 优化了预览并发与异常处理,覆盖超时和转换失败场景;

4. 修复了重复渲染与缓存校验问题,确保回退和干跑行为正确;

5. 更新了预览接口契约与部署配置,补充单元测试和说明文档。
kaywxs 1 долоо хоног өмнө
parent
commit
329c2622b9

+ 11 - 3
CODEX_BACKEND_PERSISTENCE_GUIDE.md

@@ -1,6 +1,6 @@
 # 文档管理系统后端持久化实施指导(供 Codex 使用)
 
-> Q3-B业务规则、正文检索和原文件预览同步版本:1.4。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4。
+> LibreOffice 离线文档转换与 PDF 预览同步版本:1.6。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4。
 
 ## 1. 文档目的
 
@@ -747,6 +747,8 @@ flowchart TD
 5. 更新查看或下载计数。
 6. 写入审计日志。
 
+DOC、DOCX 预览:由 LibreOffice 离线转换为 PDF 后返回,转换失败返回统一错误码;转换衍生的 PDF 缓存以原文件 SHA-256 为键写入 `preview/`;首次转换原子落盘,后续直接命中缓存。PDF 直接返回原始文件。XLS、XLSX 不支持在线预览。
+
 ### 10.5 文件完整性
 
 - 上传时计算 SHA-256 并保存到 `file_hash`。
@@ -909,6 +911,10 @@ DMS_DB_PASSWORD=change-me
 DMS_STORAGE_ROOT=<project-root>/backend/dms-storage
 DMS_MAX_FILE_SIZE_MB=100
 DMS_BATCH_MAX_FILES=50
+DMS_OFFICE_PREVIEW_ENABLED=true
+DMS_LIBREOFFICE_EXECUTABLE=C:\Program Files\LibreOffice\program\soffice.exe
+DMS_LIBREOFFICE_TIMEOUT_SECONDS=60
+DMS_LIBREOFFICE_MAX_CONCURRENCY=2
 ```
 
 本地开发可以提供不包含真实密码的 `.env.example`。真实 `.env` 必须被 Git 忽略。
@@ -1112,13 +1118,15 @@ RESTORE_RELATION_CONFLICT      409
 - 保持权限、密级、状态、排序、字符串ID、DTO、计数和审计不变。
 - Q2-B不新增索引、迁移或数据库对象;当时OpenAPI升级为 `1.3.0`。
 
-## 17. Q3-B 1.4持久化补充
+## 17. 1.6 LibreOffice 转换持久化补充
 
 1. 使用唯一迁移`0003_q3_business_alignment_and_content_search`:`doc_document.search_text`升级为LONGTEXT,新增`content_text LONGTEXT NULL`、`content_extract_status VARCHAR(32)`、`content_extracted_at DATETIME(3) NULL`,并将用户角色CHECK收敛为USER、ADMIN。
 2. 历史AUDITOR在迁移中递增auth_version和row_version、置为DISABLED并逻辑删除;为满足新CHECK可降为USER,但不得提升为ADMIN,历史审计快照保持不变。
 3. 上传文件先在暂存区完成类型校验、哈希和正文提取,再在数据库事务内保存元数据,提交后原子移动;提取FAILED不得导致原文件丢失。编辑名称、摘要或标签时以已有content_text重建search_text。
 4. PDF正文从文本层提取;DOCX提取普通段落、表格及可安全读取的页眉页脚;DOC为UNSUPPORTED。正文与组合搜索文本只存MySQL,不存二进制或HTML,不引入Milvus、Elasticsearch或OpenSearch。
 5. 回填命令`python -m dms.backfill_document_content`要求显式数据库URL并校验`SELECT DATABASE()`严格等于`dms_test`,不得自动运行或连接dms主库。
-6. preview只读取存储根内的原始普通文件,校验相对路径、存在性、大小、SHA-256、扩展名、文件头和DOCX ZIP结构;不创建preview缓存,不调用Office/WPS/LibreOffice。
+6. preview 统一路径 `/api/v1/documents/{id}/preview`:PDF 直接读取原始文件;DOC、DOCX 命中 `preview/{fileHash}.pdf` 缓存,未命中则启动 LibreOffice 转换,原子写入缓存后返回。转换配置通过环境变量 `DMS_OFFICE_PREVIEW_ENABLED`、`DMS_LIBREOFFICE_EXECUTABLE`、`DMS_LIBREOFFICE_TIMEOUT_SECONDS`、`DMS_LIBREOFFICE_MAX_CONCURRENCY` 提供。不调用 Microsoft Office 或 WPS。
+7. DOC 上传时由 LibreOffice 转换为 PDF 后提取正文;DOCX 仍使用原生 DOCX 解析提取正文。
+8. 提供显式回填命令 `python -m dms.backfill_preview_cache --dry-run` 为既有 DOC/DOCX 生成预览缓存;`--limit` 限制批量大小;支持幂等执行,已有有效缓存跳过。
 7. 正式部署前必须备份数据库并依次应用`0002_add_restore_audit_action`与`0003_q3_business_alignment_and_content_search`。若正文已写入或LONGTEXT内容无法安全降为TEXT,0003 downgrade应明确阻止而非静默丢失。
 8. 当前正文检索使用参数化、转义后的`LIKE`满足现阶段数据规模;随着文档数量和正文体积增长,会出现大文本扫描性能风险。引入MySQL FULLTEXT或独立搜索服务前必须完成中文分词、相关性、权限过滤和迁移方案评审,不得在本阶段自行扩展。

+ 14 - 5
DMS_API_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统接口契约
 
-> 契约版本:1.5
-> 状态:已确认,统一方案上传与同名覆盖接口基线
+> 契约版本:1.6
+> 状态:已确认,LibreOffice 离线文档转换与 PDF 预览接口基线
 > 外部字段风格:`camelCase`  
 > 数据库字段风格:`snake_case`
 
@@ -1003,10 +1003,16 @@ mainPlanRowVersion=<integer>
 
 ### 13.1 `GET /api/v1/documents/{id}/preview`
 
-- PDF成功返回 inline Blob;
-- DOCX成功返回原始DOCX二进制和正式MIME;DOC、XLS、XLSX返回 `415 PREVIEW_UNAVAILABLE`;
+- PDF 成功返回 inline Blob,Content-Type 为 `application/pdf`;
+- DOC、DOCX 由 LibreOffice 离线转换为 PDF 后返回,Content-Type 统一为 `application/pdf`;
+- XLS、XLSX 返回 `415 PREVIEW_UNAVAILABLE`;
 - 无查看权限返回 `403 DOCUMENT_VIEW_FORBIDDEN`;
-- 文件缺失返回 `404 FILE_NOT_FOUND`。
+- 文件缺失返回 `404 FILE_NOT_FOUND`;
+- 转换器未启用或 LibreOffice 不可用时返回 `503 PREVIEW_CONVERTER_UNAVAILABLE`;
+- 转换超时时返回 `504 PREVIEW_CONVERSION_TIMEOUT`;
+- 转换进程退出码非零、未生成输出或输出非有效 PDF 时返回 `502 PREVIEW_CONVERSION_FAILED`。
+
+预览接口不新增路径;DOC/DOCX 预览 PDF 为可重新生成的衍生缓存,不属于正式档案;原始 DOC/DOCX 继续通过下载接口返回。
 
 ### 13.2 `GET /api/v1/documents/{id}/download`
 
@@ -1264,6 +1270,9 @@ RESOURCE_NOT_FOUND
 FILE_NOT_FOUND
 UNSUPPORTED_FILE_TYPE
 PREVIEW_UNAVAILABLE
+PREVIEW_CONVERTER_UNAVAILABLE
+PREVIEW_CONVERSION_TIMEOUT
+PREVIEW_CONVERSION_FAILED
 PAYLOAD_TOO_LARGE
 BATCH_MANIFEST_MISMATCH
 DATA_VERSION_CONFLICT

+ 40 - 4
DMS_FUNCTION_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统功能契约
 
-> 契约版本:1.5
-> 状态:已确认,统一方案上传与同名覆盖执行基线
+> 契约版本:1.6
+> 状态:已确认,LibreOffice 离线文档转换与 PDF 预览基线
 > 适用范围:前端、后端、数据库、联调、测试与验收
 
 ## 1. 契约效力
@@ -457,7 +457,40 @@ INDEX(is_deleted, deleted_at, id)
 
 迁移不得新增表或修改历史审计,必须提供安全的upgrade和downgrade。
 
-## 15. 完成判定
+
+## 15. LibreOffice 离线文档转换与 PDF 预览
+
+统一预览接口 `GET /api/v1/documents/{id}/preview` 行为:
+
+- PDF:直接返回原始 PDF 二进制,Content-Type 为 `application/pdf`;
+- DOC、DOCX:调用 LibreOffice 离线转换为 PDF 后返回,Content-Type 为 `application/pdf`;
+- 转换失败时返回正式错误码,不得返回原始 DOC/DOCX 冒充 PDF;
+- 转换生成的 PDF 为预览衍生缓存,不是档案原件,原始 DOC/DOCX 仍保留并可通过下载接口获取;
+- XLS、XLSX 继续返回 `415 PREVIEW_UNAVAILABLE`;
+- 不调用 Microsoft Office、WPS 或任何在线云服务。
+
+转换错误码:
+
+- `PREVIEW_CONVERTER_UNAVAILABLE`:转换器未启用或 LibreOffice 可执行文件不存在/不可执行;
+- `PREVIEW_CONVERSION_TIMEOUT`:转换超出配置时间;
+- `PREVIEW_CONVERSION_FAILED`:LibreOffice 退出码非零、未生成输出或输出非有效 PDF。
+
+缓存规则:
+
+- 预览缓存目录为 `backend/dms-storage/preview/`;
+- 缓存键基于原文件 SHA-256,例如 `preview/{fileHash}.pdf`,可通过前两位哈希分层;
+- 缓存为按需生成,首次转换后原子写入;再次预览直接命中缓存,不重复启动 LibreOffice;
+- 旧缓存可在后续维护中由显式回填/清理命令处理,业务接口不在事务中扫描或物理清理;
+- 文件覆盖后原文件哈希变化,新预览自动使用新缓存路径。
+
+DOC 正文检索规则:
+
+- DOCX 继续使用原生 DOCX 解析提取正文,不改为先转 PDF 再提取;
+- DOC 上传时由 LibreOffice 转换为 PDF,再由现有 PDF 文字提取能力提取正文;
+- DOC 转换失败时 `content_extract_status` 标记为 `FAILED`,不得写入空字符串冒充成功;
+- 文档名称、概述、标签与正文共同写入 `search_text`。
+
+## 16. 完成判定
 
 只有同时满足以下条件,第一阶段才算完成:
 
@@ -474,6 +507,9 @@ INDEX(is_deleted, deleted_at, id)
 11. 回收站和三类文档恢复符合文件完整性、关系恢复、乐观锁和审计规则。
 12. 所有正式界面枚举的中文展示标签由后端统一UI字典配置提供,前端不重复维护同义映射。
 13. 主案、全部方案、最近更新、直属子方案和主案已挂载附件均支持基于文档自身 `updated_at` 的统一UTC时间范围检索。
+14. DOC、DOCX 通过 LibreOffice 离线转换为 PDF 预览;PDF 直接预览;XLS、XLSX 不支持在线预览。
+15. DOC 可通过 LibreOffice 转换后回填 PDF 正文至 `content_text`;DOCX 仍使用原生 DOCX 解析提取正文。
+16. 预览缓存以原文件 SHA-256 为键,位于 `backend/dms-storage/preview/`;覆盖上传后使用新文件哈希生成新缓存。
 
 ## 15A. 统一UI展示字典
 
@@ -496,7 +532,7 @@ INDEX(is_deleted, deleted_at, id)
 6. 时间条件必须在数据库查询层、权限可见性判定和分页之前执行,不得读取完整结果后用Python过滤。
 7. 检索不增加查看次数、下载次数或审计记录,不改变既有权限、密级、状态、排序、字符串ID、DTO和附件共享规则。
 
-## 16. 变更控制
+## 17. 变更控制
 
 执行会话不得直接修改本文件。需要变更时,应:
 

+ 2 - 2
FUNCTION_AND_API_SPECIFICATION.md

@@ -1,6 +1,6 @@
 # 方案计划文档管理系统功能与接口规格
 
-> Q3-B业务规则、正文检索和原文件预览同步版本:1.4。实现以 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4为最终依据。
+> 文档转换与 PDF 预览同步版本:1.6。实现以 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4为最终依据。
 
 ## 1. 文档目的
 
@@ -357,7 +357,7 @@
 
 ### F-FILE-001 预览文件
 
-后端验证文档有效性和用户查看权限后,以流或预览地址返回文件
+后端验证文档有效性和用户查看权限后返回文件。PDF 直接返回原文件;DOC、DOCX 先由 LibreOffice 离线转换为 PDF 再返回;XLS、XLSX 不支持在线预览。转换失败返回 `PREVIEW_CONVERTER_UNAVAILABLE`、`PREVIEW_CONVERSION_TIMEOUT` 或 `PREVIEW_CONVERSION_FAILED`
 
 ### F-FILE-002 下载文件
 

+ 6 - 0
backend/.env.example

@@ -12,6 +12,12 @@ DMS_MAX_FILE_SIZE=52428800
 DMS_MAX_UPLOAD_SIZE=104857600
 DMS_BATCH_MAX_FILES=50
 
+# LibreOffice 离线转换配置(DOC/DOCX 转 PDF 预览)
+DMS_OFFICE_PREVIEW_ENABLED=true
+DMS_LIBREOFFICE_EXECUTABLE=C:\\Program Files\\LibreOffice\\program\\soffice.exe
+DMS_LIBREOFFICE_TIMEOUT_SECONDS=60
+DMS_LIBREOFFICE_MAX_CONCURRENCY=2
+
 # B2配置真实随机密钥;禁止提交真实密钥
 DMS_JWT_SECRET=replace-with-a-random-secret
 

+ 2 - 0
backend/dms/__init__.py

@@ -13,6 +13,7 @@ from dms.config import load_dms_config
 from dms.extensions import db
 from dms.services.ui_dictionary_service import load_ui_dictionary_config
 from dms.storage.paths import ensure_storage_directories
+from dms.services.document_converter import create_converter
 
 
 def init_dms(app: Flask) -> None:
@@ -21,6 +22,7 @@ def init_dms(app: Flask) -> None:
     app.extensions["dms_ui_dictionaries"] = load_ui_dictionary_config(
         app.config["DMS_UI_DICTIONARY_CONFIG_PATH"]
     )
+    app.extensions["dms_document_converter"] = create_converter(app.config)
     db.init_app(app)
 
     # 导入模型以确保SQLAlchemy metadata完整;不触发数据库连接。

+ 181 - 0
backend/dms/backfill_preview_cache.py

@@ -0,0 +1,181 @@
+"""显式、幂等地为既有 DOC/DOCX/PDF 生成或校验预览缓存。
+
+用法:
+    python -m dms.backfill_preview_cache --dry-run
+    python -m dms.backfill_preview_cache --limit 100
+
+仅允许显式配置并连接既有 ``dms_test``:
+``python -m dms.backfill_preview_cache``。
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+from pathlib import Path
+from datetime import datetime, timezone
+
+from flask import Flask, current_app
+from sqlalchemy import select, text
+from sqlalchemy.engine import make_url
+
+from dms import init_dms
+from dms.common.enums import ContentExtractStatus
+from dms.extensions import db
+from dms.models import Document
+from dms.services.document_converter import create_converter
+from dms.services.preview_service import PreviewService, extract_doc_content_via_pdf
+from dms.services.document_content_service import build_search_text
+from dms.storage.paths import preview_cache_path, resolve_storage_path
+
+logger = logging.getLogger(__name__)
+
+
+def _database_gate() -> None:
+    configured = os.environ.get("DMS_DATABASE_URL", "").strip()
+    if not configured:
+        raise RuntimeError("必须显式配置DMS_DATABASE_URL并指向dms_test")
+    try:
+        configured_database = make_url(configured).database
+    except Exception as exc:
+        raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
+    if configured_database != "dms_test":
+        raise RuntimeError("预览缓存回填命令只允许连接dms_test")
+    database_name = db.session.scalar(text("SELECT DATABASE()"))
+    if database_name != "dms_test":
+        raise RuntimeError("预览缓存回填命令只允许连接dms_test")
+
+
+def backfill(*, dry_run: bool = False, limit: int | None = None) -> dict[str, int]:
+    _database_gate()
+    result = {
+        "processed": 0,
+        "skipped": 0,
+        "failed": 0,
+        "doc_content_backfilled": 0,
+    }
+
+    converter = create_converter(
+        {
+            "DMS_OFFICE_PREVIEW_ENABLED": current_app.config.get(
+                "DMS_OFFICE_PREVIEW_ENABLED", True
+            ),
+            "DMS_LIBREOFFICE_EXECUTABLE": current_app.config.get(
+                "DMS_LIBREOFFICE_EXECUTABLE", ""
+            ),
+            "DMS_LIBREOFFICE_TIMEOUT_SECONDS": current_app.config.get(
+                "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
+            ),
+            "DMS_LIBREOFFICE_MAX_CONCURRENCY": current_app.config.get(
+                "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
+            ),
+        }
+    )
+    storage_root = Path(
+        current_app.config.get("DMS_STORAGE_ROOT", "")
+    ).expanduser().resolve() or (
+        Path(__file__).resolve().parents[2] / "dms-storage"
+    )
+
+    query = (
+        select(Document)
+        .where(
+            Document.is_deleted.is_(False),
+            Document.file_extension.in_(["doc", "docx", "pdf"]),
+        )
+        .order_by(Document.id.asc())
+    )
+    if limit:
+        query = query.limit(limit)
+    documents = db.session.scalars(query).all()
+
+    for document in documents:
+        extension = document.file_extension.lower().lstrip(".")
+        try:
+            original_path = resolve_storage_path(
+                document.file_relative_path, storage_root
+            )
+            if not original_path.is_file():
+                logger.warning("文件不存在:document_id=%s", document.id)
+                result["failed"] += 1
+                continue
+            if extension == "pdf":
+                result["skipped"] += 1
+                continue
+
+            preview_service = PreviewService(storage_root, converter)
+            cache_path = preview_cache_path(document.file_hash, storage_root)
+            if preview_service.validate_cached_pdf(cache_path):
+                result["skipped"] += 1
+            elif dry_run:
+                logger.info(
+                    "dry-run:需要生成预览缓存 document_id=%s", document.id
+                )
+                result["processed"] += 1
+            else:
+                preview_service.preview_path_for(document)
+                result["processed"] += 1
+
+            if extension == "doc" and not dry_run:
+                if document.content_extract_status in {
+                    ContentExtractStatus.SUCCESS.value,
+                    ContentExtractStatus.EMPTY.value,
+                }:
+                    pass
+                else:
+                    text_value, status = extract_doc_content_via_pdf(
+                        original_path, converter, document.file_hash
+                    )
+                    if status != ContentExtractStatus.FAILED.value:
+                        document.content_text = text_value
+                        document.content_extract_status = status
+                        document.content_extracted_at = datetime.now(
+                            timezone.utc
+                        ).replace(tzinfo=None)
+                        document.search_text = build_search_text(
+                            document.document_name,
+                            document.summary,
+                            document.tags,
+                            text_value,
+                        )
+                        db.session.commit()
+                        result["doc_content_backfilled"] += 1
+
+        except Exception:
+            logger.exception("回填预览缓存失败:document_id=%s", document.id)
+            result["failed"] += 1
+            db.session.rollback()
+
+    return result
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="DMS 预览缓存回填命令")
+    parser.add_argument(
+        "--dry-run", action="store_true", help="仅输出需要处理的文档,不实际转换"
+    )
+    parser.add_argument(
+        "--limit", type=int, default=None, help="限制处理的文档数量"
+    )
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+
+    app = Flask("dms-preview-backfill")
+    init_dms(app)
+    with app.app_context():
+        outcome = backfill(dry_run=args.dry_run, limit=args.limit)
+    print(
+        "预览缓存回填完成:"
+        f"处理{outcome['processed']}个,跳过{outcome['skipped']}个,"
+        f"失败{outcome['failed']}个,"
+        f"DOC正文回填{outcome['doc_content_backfilled']}个。"
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 15 - 0
backend/dms/common/errors.py

@@ -113,6 +113,21 @@ class PreviewUnavailableError(DmsError):
     status_code = 415
     default_message = "当前文件类型不支持在线预览"
 
+class PreviewConverterUnavailableError(DmsError):
+    code = "PREVIEW_CONVERTER_UNAVAILABLE"
+    status_code = 503
+    default_message = "预览转换器未配置或不可用"
+
+class PreviewConversionTimeoutError(DmsError):
+    code = "PREVIEW_CONVERSION_TIMEOUT"
+    status_code = 504
+    default_message = "文档转换超时,请稍后重试"
+
+class PreviewConversionFailedError(DmsError):
+    code = "PREVIEW_CONVERSION_FAILED"
+    status_code = 502
+    default_message = "文档转换为PDF失败"
+
 
 class BatchManifestMismatchError(InvalidArgumentError):
     code = "BATCH_MANIFEST_MISMATCH"

+ 24 - 0
backend/dms/config.py

@@ -7,6 +7,9 @@ from pathlib import Path
 from typing import Any
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
+import shutil
+
+
 
 BACKEND_ROOT = Path(__file__).resolve().parents[1]
 DEFAULT_STORAGE_ROOT = BACKEND_ROOT / "dms-storage"
@@ -60,6 +63,23 @@ def load_dms_config() -> dict[str, Any]:
     except (ZoneInfoNotFoundError, ValueError) as exc:
         raise ValueError("DMS_BUSINESS_TIMEZONE必须是有效的IANA时区") from exc
 
+    office_preview_enabled = (
+        os.environ.get("DMS_OFFICE_PREVIEW_ENABLED", "true").strip().lower()
+        in {"1", "true", "yes", "on"}
+    )
+    libreoffice_executable = (
+        os.environ.get("DMS_LIBREOFFICE_EXECUTABLE", "").strip()
+        or shutil.which("soffice")
+        or shutil.which("libreoffice")
+        or ""
+    )
+    libreoffice_timeout_seconds = _positive_int(
+        "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
+    )
+    libreoffice_max_concurrency = _positive_int(
+        "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
+    )
+
     return {
         "SQLALCHEMY_DATABASE_URI": database_url or DEFAULT_DATABASE_URL,
         "SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -84,4 +104,8 @@ def load_dms_config() -> dict[str, Any]:
         "DMS_UI_DICTIONARY_CONFIG_PATH": str(
             ui_dictionary_config_path.resolve()
         ),
+        "DMS_OFFICE_PREVIEW_ENABLED": office_preview_enabled,
+        "DMS_LIBREOFFICE_EXECUTABLE": libreoffice_executable,
+        "DMS_LIBREOFFICE_TIMEOUT_SECONDS": libreoffice_timeout_seconds,
+        "DMS_LIBREOFFICE_MAX_CONCURRENCY": libreoffice_max_concurrency,
     }

+ 252 - 0
backend/dms/services/document_converter.py

@@ -0,0 +1,252 @@
+"""文档格式转换抽象,支持 LibreOffice 离线转换与测试用的 Fake 实现。"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+import threading
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+import fitz
+
+from dms.common.errors import (
+    PreviewConversionFailedError,
+    PreviewConverterUnavailableError,
+    PreviewConversionTimeoutError,
+)
+
+logger = logging.getLogger(__name__)
+
+_PDF_MAGIC = b"%PDF-"
+
+
+class DocumentConverter(ABC):
+    """文档转换器接口;业务层只依赖此接口,方便后续替换实现。"""
+
+    @abstractmethod
+    def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
+        """将源文件转换为 PDF 并返回二进制内容。
+
+        实现必须保证:
+        - 不依赖当前工作目录;
+        - 异常转换为 PreviewConversion* 业务错误;
+        - 不泄露临时目录或命令行。
+        """
+        raise NotImplementedError
+
+    @property
+    @abstractmethod
+    def available(self) -> bool:
+        """转换器是否可用。"""
+        raise NotImplementedError
+
+
+class LibreOfficeDocumentConverter(DocumentConverter):
+    """使用 LibreOffice headless 将 DOC/DOCX 转换为 PDF。"""
+
+    def __init__(
+        self,
+        executable: str,
+        timeout_seconds: int,
+        max_concurrency: int,
+        enabled: bool = True,
+    ) -> None:
+        self._executable = executable
+        self._timeout_seconds = timeout_seconds
+        self._semaphore = threading.Semaphore(max(1, max_concurrency))
+        self._enabled = enabled
+        self._in_flight: dict[str, LibreOfficeDocumentConverter._ConversionTask] = {}
+        self._lock = threading.Lock()
+
+    @property
+    def available(self) -> bool:
+        if not self._enabled:
+            return False
+        if not self._executable:
+            return False
+        return Path(self._executable).is_file()
+
+    class _ConversionTask:
+        __slots__ = ("event", "result")
+
+        def __init__(self) -> None:
+            self.event = threading.Event()
+            self.result: bytes | None = None
+
+    def _join_or_register(self, file_hash: str) -> tuple[_ConversionTask, bool]:
+        """注册新的转换任务或加入已有任务。
+
+        返回 (task, is_owner)。is_owner=True 表示当前线程需要执行转换。
+        非 owner 线程持有 task 引用,即使 owner 清理 _in_flight 也能读取结果。
+        """
+        with self._lock:
+            if file_hash in self._in_flight:
+                return self._in_flight[file_hash], False
+            task = self._ConversionTask()
+            self._in_flight[file_hash] = task
+            return task, True
+
+    def _set_result(self, task: _ConversionTask, result: bytes | None) -> None:
+        task.result = result
+        task.event.set()
+
+    def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
+        if not self.available:
+            raise PreviewConverterUnavailableError()
+
+        task, is_owner = self._join_or_register(file_hash)
+        if not is_owner:
+            if not task.event.wait(self._timeout_seconds):
+                raise PreviewConversionTimeoutError()
+            if task.result is None:
+                raise PreviewConversionFailedError()
+            return task.result
+
+        try:
+            result = self._convert_locked(source_path, file_hash)
+            self._set_result(task, result)
+            return result
+        except Exception:
+            self._set_result(task, None)
+            raise
+        finally:
+            with self._lock:
+                self._in_flight.pop(file_hash, None)
+
+    def _convert_locked(self, source_path: Path, file_hash: str) -> bytes:
+        work_dir = Path(tempfile.mkdtemp(prefix=f"dms-lo-{file_hash[:8]}-"))
+        user_dir = Path(tempfile.mkdtemp(prefix=f"dms-locfg-{file_hash[:8]}-"))
+        try:
+            output_dir = work_dir / "out"
+            output_dir.mkdir(parents=True, exist_ok=True)
+            input_file = work_dir / source_path.name
+            input_file.write_bytes(source_path.read_bytes())
+
+            # LibreOffice 在 headless 模式下输出文件名与输入文件名一致,扩展名改为 pdf
+            base_name = source_path.stem
+            expected_output = output_dir / f"{base_name}.pdf"
+
+            user_url = quote(str(user_dir.as_posix()), safe="/:")
+            args = [
+                self._executable,
+                "--headless",
+                "--nologo",
+                "--nodefault",
+                "--nofirststartwizard",
+                "--nolockcheck",
+                "--convert-to",
+                "pdf",
+                "--outdir",
+                str(output_dir),
+                f"-env:UserInstallation=file:///{user_url}",
+                str(input_file),
+            ]
+
+            with self._semaphore:
+                try:
+                    process = subprocess.Popen(
+                        args,
+                        stdout=subprocess.PIPE,
+                        stderr=subprocess.PIPE,
+                        cwd=str(work_dir),
+                    )
+                except OSError as exc:
+                    logger.error("启动 LibreOffice 失败:%s", exc.__class__.__name__)
+                    raise PreviewConverterUnavailableError() from exc
+
+                try:
+                    stdout, stderr = process.communicate(
+                        timeout=self._timeout_seconds
+                    )
+                except subprocess.TimeoutExpired as exc:
+                    logger.warning("LibreOffice 转换超时")
+                    _terminate_process(process)
+                    raise PreviewConversionTimeoutError() from exc
+                finally:
+                    if process.poll() is None:
+                        _terminate_process(process)
+
+            if process.returncode != 0:
+                logger.error(
+                    "LibreOffice 退出码非零:returncode=%s",
+                    process.returncode,
+                )
+                raise PreviewConversionFailedError()
+
+            if not expected_output.is_file():
+                logger.error("LibreOffice 未生成预期 PDF 文件")
+                raise PreviewConversionFailedError()
+
+            pdf_bytes = expected_output.read_bytes()
+            if not pdf_bytes.startswith(_PDF_MAGIC):
+                logger.error("LibreOffice 输出文件头不是 PDF")
+                raise PreviewConversionFailedError()
+
+            return pdf_bytes
+        finally:
+            try:
+                shutil.rmtree(work_dir, ignore_errors=True)
+                shutil.rmtree(user_dir, ignore_errors=True)
+            except Exception:
+                logger.exception("清理 LibreOffice 临时目录失败")
+
+
+def _terminate_process(process: subprocess.Popen[Any]) -> None:
+    """终止 LibreOffice 进程及其子进程。"""
+    try:
+        process.terminate()
+        process.wait(timeout=5)
+    except Exception:
+        try:
+            process.kill()
+        except Exception:
+            pass
+
+
+class FakeDocumentConverter(DocumentConverter):
+    """测试用转换器:将源文件内容复制为 PDF 字节或按规则生成 PDF。"""
+
+    def __init__(self, enabled: bool = True) -> None:
+        self._enabled = enabled
+        self.calls: list[tuple[Path, str]] = []
+
+    @property
+    def available(self) -> bool:
+        return self._enabled
+
+    def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
+        self.calls.append((source_path, file_hash))
+        if not self._enabled:
+            raise PreviewConverterUnavailableError()
+        source_text = source_path.read_bytes().decode("utf-8", errors="replace")
+        document = fitz.open()
+        try:
+            page = document.new_page()
+            page.insert_text((72, 72), source_text[:4096])
+            return document.tobytes()
+        finally:
+            document.close()
+
+
+def create_converter(config: dict[str, Any]) -> DocumentConverter:
+    """根据 Flask 配置创建默认转换器。"""
+    return LibreOfficeDocumentConverter(
+        executable=config.get("DMS_LIBREOFFICE_EXECUTABLE", ""),
+        timeout_seconds=config.get("DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60),
+        max_concurrency=config.get("DMS_LIBREOFFICE_MAX_CONCURRENCY", 2),
+        enabled=config.get("DMS_OFFICE_PREVIEW_ENABLED", True),
+    )
+
+
+__all__ = [
+    "DocumentConverter",
+    "LibreOfficeDocumentConverter",
+    "FakeDocumentConverter",
+    "create_converter",
+]

+ 36 - 1
backend/dms/services/document_mutation_service.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 import logging
+import tempfile
 from typing import Any
 
 from flask import current_app
@@ -39,9 +40,11 @@ from dms.services.audit_service import business_audit
 from dms.services.authorization_service import evaluate_plan_access
 from dms.services.document_query_service import document_detail
 from dms.services.document_content_service import (
+    ContentExtraction,
     build_search_text,
     extract_document_content,
 )
+from dms.services.preview_service import extract_doc_content_via_pdf
 from dms.storage.uploads import StagedUpload, stage_upload
 from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
 
@@ -247,7 +250,39 @@ def _create_record(
     name = _text(payload["documentName"], "documentName", 255)
     summary = _text(payload["summary"], "summary", 20000, nullable=True)
     tags = _tags(payload["tags"])
-    extraction = extract_document_content(upload.temporary_path, upload.extension)
+    if upload.extension == "doc":
+        temp_pdf: Path | None = None
+        try:
+            from dms.services.document_converter import create_converter
+            converter = create_converter(current_app.config)
+            if not converter.available:
+                extraction = ContentExtraction(
+                    text=None,
+                    status="FAILED",
+                    extracted_at=_now(),
+                )
+            else:
+                pdf_bytes = converter.convert_to_pdf(
+                    upload.temporary_path, upload.file_hash
+                )
+                with tempfile.NamedTemporaryFile(
+                    suffix=".pdf", delete=False
+                ) as temp_file:
+                    temp_file.write(pdf_bytes)
+                    temp_pdf = Path(temp_file.name)
+                extraction = extract_document_content(temp_pdf, "pdf")
+        except Exception:
+            logger.exception("DOC转换PDF提取正文失败")
+            extraction = ContentExtraction(
+                text=None,
+                status="FAILED",
+                extracted_at=_now(),
+            )
+        finally:
+            if temp_pdf is not None:
+                temp_pdf.unlink(missing_ok=True)
+    else:
+        extraction = extract_document_content(upload.temporary_path, upload.extension)
     final_created = False
     committed = False
     old_file_path = None

+ 9 - 13
backend/dms/services/file_read_service.py

@@ -27,6 +27,7 @@ from dms.services.audit_service import business_audit
 from dms.services.authorization_service import evaluate_plan_access
 from dms.services.authorization_service import require_attachment_access
 from dms.services.document_query_service import _require_plan_access
+from dms.services.preview_service import PreviewService
 from dms.services.recycle_bin_service import _verify_file
 from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
 
@@ -114,21 +115,16 @@ def preview_document(document_id: int):
     try:
         path = verified.path
         extension = document.file_extension.lower().lstrip(".")
-        if extension == "pdf":
-            mime_type = "application/pdf"
-        elif extension == "docx":
-            mime_type = (
-                "application/vnd.openxmlformats-officedocument."
-                "wordprocessingml.document"
-            )
-        else:
-            raise PreviewUnavailableError()
+        preview_service = PreviewService.current()
+        preview_path = preview_service.preview_path_for(document)
         verified.close()
         response = send_file(
-            path,
-            mimetype=mime_type,
+            preview_path,
+            mimetype="application/pdf",
             as_attachment=False,
-            download_name=_download_name(document.original_file_name),
+            download_name=_download_name(
+                f"{Path(document.original_file_name).stem}.pdf"
+            ),
             conditional=True,
         )
     except Exception:
@@ -137,7 +133,7 @@ def preview_document(document_id: int):
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["Cache-Control"] = "private, no-store"
     response.headers["Content-Disposition"] = _inline_content_disposition(
-        document.original_file_name
+        f"{Path(document.original_file_name).stem}.pdf"
     )
     return response
 

+ 182 - 0
backend/dms/services/preview_service.py

@@ -0,0 +1,182 @@
+"""统一预览服务:PDF直接返回;DOC/DOCX按需转换为PDF并缓存。"""
+
+from __future__ import annotations
+
+import logging
+import os
+import tempfile
+from pathlib import Path
+
+import fitz
+from flask import current_app
+
+from dms.common.enums import ContentExtractStatus
+from dms.common.errors import (
+    PreviewConversionFailedError,
+    PreviewConversionTimeoutError,
+    PreviewConverterUnavailableError,
+    PreviewUnavailableError,
+)
+from dms.services.document_converter import DocumentConverter, create_converter
+from dms.storage.paths import (
+    UnsafeStoragePathError,
+    is_path_inside_preview,
+    preview_cache_path,
+    resolve_storage_path,
+)
+
+logger = logging.getLogger(__name__)
+
+_CONVERTIBLE_EXTENSIONS = {"doc", "docx"}
+
+
+def _get_converter() -> DocumentConverter:
+    """从 Flask 应用配置获取当前转换器。"""
+    config = current_app.config
+    # 应用初始化时若已挂载转换器实例则优先复用,否则动态创建
+    converter = current_app.extensions.get("dms_document_converter")
+    if converter is None:
+        converter = create_converter(config)
+        current_app.extensions["dms_document_converter"] = converter
+    return converter
+
+
+class PreviewService:
+    """文档预览服务:管理 PDF 直接预览与 Office 转换后预览。"""
+
+    def __init__(self, storage_root: Path, converter: DocumentConverter) -> None:
+        self.storage_root = storage_root
+        self.converter = converter
+
+    @classmethod
+    def current(cls) -> "PreviewService":
+        root = Path(current_app.config["DMS_STORAGE_ROOT"]).expanduser().resolve()
+        return cls(root, _get_converter())
+
+    def preview_path_for(self, document) -> Path:
+        """返回最终用于发送文件的磁盘路径。
+
+        - PDF: 原始文件路径
+        - DOC/DOCX: 缓存的 PDF 路径(按需转换)
+        - 其他: 直接抛出 PreviewUnavailableError
+        """
+        extension = document.file_extension.lower().lstrip(".")
+        original = resolve_storage_path(
+            document.file_relative_path,
+            self.storage_root,
+        )
+
+        if extension == "pdf":
+            return original
+
+        if extension not in _CONVERTIBLE_EXTENSIONS:
+            raise PreviewUnavailableError()
+
+        return self._convertible_preview_path(
+            original, document.file_hash, extension
+        )
+
+    def _convertible_preview_path(
+        self, source_path: Path, file_hash: str, extension: str
+    ) -> Path:
+        if not self.converter.available:
+            raise PreviewConverterUnavailableError()
+
+        cache = preview_cache_path(file_hash, self.storage_root)
+        if self.validate_cached_pdf(cache):
+            return cache
+
+        try:
+            pdf_bytes = self.converter.convert_to_pdf(source_path, file_hash)
+        except (PreviewConverterUnavailableError, PreviewConversionTimeoutError):
+            raise
+        except Exception as exc:
+            logger.exception("文档转换失败")
+            raise PreviewConversionFailedError() from exc
+
+        if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
+            raise PreviewConversionFailedError()
+
+        # 原子写入:先写临时文件再重命名
+        cache.parent.mkdir(parents=True, exist_ok=True)
+        temp_file: Path | None = None
+        try:
+            with tempfile.NamedTemporaryFile(
+                dir=cache.parent,
+                prefix=f".{file_hash}.",
+                suffix=".tmp",
+                delete=False,
+            ) as temp_stream:
+                temp_stream.write(pdf_bytes)
+                temp_file = Path(temp_stream.name)
+            os.replace(temp_file, cache)
+        except OSError as exc:
+            if temp_file is not None:
+                temp_file.unlink(missing_ok=True)
+            logger.exception("写入预览缓存失败")
+            raise PreviewConversionFailedError() from exc
+
+        return cache
+
+    def validate_cached_pdf(self, cache_path: Path) -> bool:
+        """验证缓存文件是否为有效 PDF 且位于 preview 目录内。"""
+        try:
+            if not is_path_inside_preview(cache_path, self.storage_root):
+                return False
+            if not cache_path.is_file() or cache_path.stat().st_size == 0:
+                return False
+            header = cache_path.read_bytes()[:8]
+            if not header.startswith(b"%PDF-"):
+                return False
+            with fitz.open(stream=cache_path.read_bytes(), filetype="pdf") as _:
+                return True
+        except Exception:
+            return False
+
+
+def extract_doc_content_via_pdf(
+    source_path: Path,
+    converter: DocumentConverter,
+    file_hash: str,
+) -> tuple[str | None, str]:
+    """将 DOC 转换为 PDF 后提取正文。
+
+    返回 (text, status)。status 为 SUCCESS、EMPTY 或 FAILED。
+    """
+    from dms.services.document_content_service import _pdf_text
+
+    if not converter.available:
+        return None, ContentExtractStatus.FAILED.value
+
+    try:
+        pdf_bytes = converter.convert_to_pdf(source_path, file_hash)
+    except Exception:
+        logger.exception("DOC 转 PDF 提取正文失败")
+        return None, ContentExtractStatus.FAILED.value
+
+    if not pdf_bytes or not pdf_bytes.startswith(b"%PDF-"):
+        return None, ContentExtractStatus.FAILED.value
+
+    temp_path: Path | None = None
+    try:
+        with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
+            temp_file.write(pdf_bytes)
+            temp_path = Path(temp_file.name)
+        text = _pdf_text(temp_path)
+    except Exception:
+        logger.exception("DOC 转换后的 PDF 提取正文失败")
+        return None, ContentExtractStatus.FAILED.value
+    finally:
+        if temp_path is not None:
+            temp_path.unlink(missing_ok=True)
+
+    return text or None, (
+        ContentExtractStatus.SUCCESS.value if text
+        else ContentExtractStatus.EMPTY.value
+    )
+
+
+__all__ = [
+    "PreviewService",
+    "extract_doc_content_via_pdf",
+]

+ 25 - 0
backend/dms/storage/paths.py

@@ -45,3 +45,28 @@ def resolve_storage_path(
     if not candidate.is_relative_to(root):
         raise UnsafeStoragePathError("存储路径超出DMS存储根目录")
     return candidate
+
+
+def preview_cache_path(file_hash: str, storage_root: str | Path) -> Path:
+    """基于文件SHA-256返回预览PDF缓存路径(支持前两位分层)。"""
+    if not file_hash or len(file_hash) < 2:
+        raise UnsafeStoragePathError("文件哈希无效")
+    root = Path(storage_root).expanduser().resolve()
+    prefix = file_hash[:2].lower()
+    candidate = (root / "preview" / prefix / f"{file_hash}.pdf").resolve()
+    if not candidate.is_relative_to(root / "preview"):
+        raise UnsafeStoragePathError("预览缓存路径超出预览目录")
+    return candidate
+
+
+def preview_directory_for_hash(file_hash: str, storage_root: str | Path) -> Path:
+    """返回预览缓存文件所在目录,确保位于 preview/ 下。"""
+    path = preview_cache_path(file_hash, storage_root)
+    return path.parent
+
+
+def is_path_inside_preview(path: Path, storage_root: str | Path) -> bool:
+    """检查路径是否位于预览缓存目录内。"""
+    root = Path(storage_root).expanduser().resolve()
+    resolved = path.resolve()
+    return resolved.is_relative_to(root / "preview")

+ 33 - 0
backend/openapi/components/responses.yaml

@@ -72,3 +72,36 @@ UnsupportedMedia:
     application/json:
       schema:
         $ref: './schemas.yaml#/ErrorResponse'
+
+PreviewConverterUnavailable:
+  description: 预览转换器不可用
+  content:
+    application/json:
+      schema: {$ref: './schemas.yaml#/ErrorResponse'}
+      example:
+        code: PREVIEW_CONVERTER_UNAVAILABLE
+        message: 预览转换器未配置或LibreOffice不可用
+        details: null
+        requestId: 00000000-0000-0000-0000-000000000000
+
+PreviewConversionTimeout:
+  description: 预览转换超时
+  content:
+    application/json:
+      schema: {$ref: './schemas.yaml#/ErrorResponse'}
+      example:
+        code: PREVIEW_CONVERSION_TIMEOUT
+        message: 文档转换超时,请稍后重试
+        details: null
+        requestId: 00000000-0000-0000-0000-000000000000
+
+PreviewConversionFailed:
+  description: 预览转换失败
+  content:
+    application/json:
+      schema: {$ref: './schemas.yaml#/ErrorResponse'}
+      example:
+        code: PREVIEW_CONVERSION_FAILED
+        message: 文档转换为PDF失败
+        details: null
+        requestId: 00000000-0000-0000-0000-000000000000

+ 2 - 2
backend/openapi/openapi.yaml

@@ -1,8 +1,8 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.5.0
-  description: DMS第一阶段接口;Q2-B统一方案、直属子方案和已挂载附件的更新时间范围检索
+  version: 1.6.0
+  description: DMS第一阶段接口;Q3-B正文检索、原文件预览及1.6 LibreOffice离线文档转换与PDF预览
 servers:
   - url: /
 paths:

+ 5 - 3
backend/openapi/paths/documents.yaml

@@ -260,19 +260,21 @@ DocumentPreview:
     - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
   get:
     operationId: previewDocument
-    summary: 原样预览PDF或DOCX文档
+    summary: PDF直接预览;DOC、DOCX由LibreOffice离线转换为PDF后预览
     responses:
       '200':
-        description: PDF或DOCX原文件流
+        description: PDF文件流(PDF原文件或DOC/DOCX转换后的PDF)
         headers:
           X-Request-Id: {schema: {type: string, format: uuid}}
         content:
           application/pdf: {schema: {type: string, format: binary}}
-          application/vnd.openxmlformats-officedocument.wordprocessingml.document: {schema: {type: string, format: binary}}
       '401': {$ref: '../components/responses.yaml#/AuthenticationError'}
       '403': {$ref: '../components/responses.yaml#/Forbidden'}
       '404': {$ref: '../components/responses.yaml#/NotFound'}
       '415': {$ref: '../components/responses.yaml#/UnsupportedMedia'}
+      '502': {$ref: '../components/responses.yaml#/PreviewConversionFailed'}
+      '503': {$ref: '../components/responses.yaml#/PreviewConverterUnavailable'}
+      '504': {$ref: '../components/responses.yaml#/PreviewConversionTimeout'}
       '500': {$ref: '../components/responses.yaml#/InternalError'}
 
 DocumentDownload:

+ 180 - 0
backend/tests/dms/services/test_document_converter.py

@@ -0,0 +1,180 @@
+"""DocumentConverter 单元测试,不依赖真实 LibreOffice。"""
+
+from __future__ import annotations
+
+import subprocess
+import threading
+import time
+from pathlib import Path
+
+import pytest
+import fitz
+
+from dms.common.errors import (
+    PreviewConversionFailedError,
+    PreviewConversionTimeoutError,
+    PreviewConverterUnavailableError,
+)
+from dms.services.document_converter import (
+    FakeDocumentConverter,
+    LibreOfficeDocumentConverter,
+)
+
+
+def test_fake_converter_available_by_default() -> None:
+    converter = FakeDocumentConverter()
+    assert converter.available is True
+
+
+def test_fake_converter_returns_pdf_bytes(tmp_path: Path) -> None:
+    converter = FakeDocumentConverter()
+    source = tmp_path / "test.docx"
+    source.write_bytes(b"hello document")
+    result = converter.convert_to_pdf(source, "abcd1234")
+    assert result.startswith(b"%PDF-")
+    with fitz.open(stream=result, filetype="pdf") as document:
+        assert "hello document" in "".join(page.get_text() for page in document)
+    assert len(converter.calls) == 1
+    assert converter.calls[0] == (source, "abcd1234")
+
+
+def test_fake_converter_unavailable_raises() -> None:
+    converter = FakeDocumentConverter(enabled=False)
+    assert converter.available is False
+    source = Path("/nonexistent/test.docx")
+    with pytest.raises(PreviewConverterUnavailableError):
+        converter.convert_to_pdf(source, "hash")
+
+
+def test_libreoffice_unavailable_when_disabled() -> None:
+    converter = LibreOfficeDocumentConverter(
+        executable="/usr/bin/soffice",
+        timeout_seconds=60,
+        max_concurrency=2,
+        enabled=False,
+    )
+    assert converter.available is False
+
+
+def test_libreoffice_unavailable_when_executable_missing() -> None:
+    converter = LibreOfficeDocumentConverter(
+        executable="/definitely/not/exists/soffice",
+        timeout_seconds=60,
+        max_concurrency=2,
+    )
+    assert converter.available is False
+    source = Path(__file__)
+    with pytest.raises(PreviewConverterUnavailableError):
+        converter.convert_to_pdf(source, "hash")
+
+
+class FakeProcess:
+    def __init__(self, returncode: int = 0, timeout: bool = False) -> None:
+        self._returncode = returncode
+        self._timeout = timeout
+
+    def communicate(self, timeout: float | None = None) -> tuple[bytes, bytes]:
+        if self._timeout:
+            raise subprocess.TimeoutExpired(cmd=["soffice"], timeout=timeout or 1)
+        return b"", b""
+
+    def poll(self) -> int | None:
+        return self._returncode
+
+    def terminate(self) -> None:
+        pass
+
+    def wait(self, timeout: float | None = None) -> int:
+        return self._returncode
+
+    @property
+    def returncode(self) -> int:
+        return self._returncode
+
+
+def test_libreoffice_conversion_fails_with_non_zero_exit(tmp_path: Path, monkeypatch) -> None:
+    real_exe = tmp_path / "soffice.exe"
+    real_exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(real_exe),
+        timeout_seconds=5,
+        max_concurrency=1,
+    )
+    assert converter.available is True
+
+    def fake_popen(*args, **kwargs):
+        return FakeProcess(returncode=1)
+
+    monkeypatch.setattr(subprocess, "Popen", fake_popen)
+
+    source = tmp_path / "test.docx"
+    source.write_bytes(b"not a real docx")
+    with pytest.raises(PreviewConversionFailedError):
+        converter.convert_to_pdf(source, "hash")
+
+
+def test_libreoffice_timeout_cleans_up(tmp_path: Path, monkeypatch) -> None:
+    real_exe = tmp_path / "soffice.exe"
+    real_exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(real_exe),
+        timeout_seconds=1,
+        max_concurrency=1,
+    )
+    assert converter.available is True
+
+    def fake_popen(*args, **kwargs):
+        return FakeProcess(timeout=True)
+
+    monkeypatch.setattr(subprocess, "Popen", fake_popen)
+
+    source = tmp_path / "test.docx"
+    source.write_bytes(b"not a real docx")
+    with pytest.raises(PreviewConversionTimeoutError):
+        converter.convert_to_pdf(source, "hash")
+
+
+def test_libreoffice_concurrent_same_hash_deduplicates(tmp_path: Path, monkeypatch) -> None:
+    real_exe = tmp_path / "soffice.exe"
+    real_exe.write_bytes(b"MZ")
+    converter = LibreOfficeDocumentConverter(
+        executable=str(real_exe),
+        timeout_seconds=5,
+        max_concurrency=2,
+    )
+    calls: list[tuple] = []
+    lock = threading.Lock()
+    ready = threading.Event()
+
+    def fake_popen(*args, **kwargs):
+        with lock:
+            calls.append(args)
+        ready.set()
+        time.sleep(0.25)
+        argv = args[0]
+        out_dir = Path(argv[argv.index("--outdir") + 1])
+        input_file = Path(argv[-1])
+        out_dir.mkdir(parents=True, exist_ok=True)
+        (out_dir / f"{input_file.stem}.pdf").write_bytes(b"%PDF-1.4 fake")
+        return FakeProcess(returncode=0)
+
+    monkeypatch.setattr(subprocess, "Popen", fake_popen)
+
+    source = tmp_path / "test.docx"
+    source.write_bytes(b"shared")
+    results: list[bytes] = []
+
+    def call() -> None:
+        results.append(converter.convert_to_pdf(source, "same_hash"))
+
+    t1 = threading.Thread(target=call)
+    t2 = threading.Thread(target=call)
+    t1.start()
+    ready.wait(timeout=1)
+    t2.start()
+    t1.join(timeout=5)
+    t2.join(timeout=5)
+
+    assert len(results) == 2
+    assert all(r == results[0] for r in results)
+    assert len(calls) == 1, "同一文件哈希应只触发一次真实转换"

+ 115 - 0
backend/tests/dms/services/test_preview_service.py

@@ -0,0 +1,115 @@
+"""PreviewService 单元测试,不依赖数据库或真实 LibreOffice。"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from dms.services.document_converter import FakeDocumentConverter
+from dms.services.preview_service import PreviewService
+from dms.storage.paths import UnsafeStoragePathError, preview_cache_path
+
+
+@pytest.fixture
+def storage_root(tmp_path: Path) -> Path:
+    root = tmp_path / "dms-storage"
+    (root / "preview").mkdir(parents=True)
+    (root / "original").mkdir(parents=True)
+    return root
+
+
+@pytest.fixture
+def preview_service(storage_root: Path) -> PreviewService:
+    return PreviewService(storage_root, FakeDocumentConverter())
+
+
+def test_preview_cache_path_with_hash_prefix(storage_root: Path) -> None:
+    path = preview_cache_path("abcdef123456", storage_root)
+    assert path.name == "abcdef123456.pdf"
+    assert path.parent.name == "ab"
+    assert path.parent.parent.name == "preview"
+
+
+def test_preview_cache_path_rejects_invalid_hash(storage_root: Path) -> None:
+    with pytest.raises(UnsafeStoragePathError):
+        preview_cache_path("", storage_root)
+
+
+def test_validate_cached_pdf_accepts_valid_pdf(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    cache = preview_cache_path("validhash", storage_root)
+    cache.parent.mkdir(parents=True, exist_ok=True)
+    # 生成 fitz 可真正打开的合法最小 PDF,避免仅写入魔数导致验证失败
+    import fitz
+
+    doc = fitz.open()
+    doc.new_page()
+    doc.save(str(cache))
+    doc.close()
+    assert preview_service.validate_cached_pdf(cache) is True
+
+
+def test_validate_cached_pdf_rejects_non_pdf(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    cache = preview_cache_path("nonpdfhash", storage_root)
+    cache.parent.mkdir(parents=True, exist_ok=True)
+    cache.write_bytes(b"not a pdf")
+    assert preview_service.validate_cached_pdf(cache) is False
+
+
+def test_validate_cached_pdf_rejects_path_outside_preview(
+    preview_service: PreviewService, tmp_path: Path
+) -> None:
+    outside = tmp_path / "outside.pdf"
+    outside.write_bytes(b"%PDF-1.4")
+    assert preview_service.validate_cached_pdf(outside) is False
+
+
+class FakeDocument:
+    def __init__(
+        self,
+        file_extension: str,
+        file_hash: str,
+        file_relative_path: str,
+    ) -> None:
+        self.file_extension = file_extension
+        self.file_hash = file_hash
+        self.file_relative_path = file_relative_path
+
+
+def test_preview_path_for_pdf_returns_original(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    original = storage_root / "original" / "sample.pdf"
+    original.write_bytes(b"%PDF-1.4")
+    document = FakeDocument("pdf", "pdfhash", "original/sample.pdf")
+    path = preview_service.preview_path_for(document)
+    assert path == original
+
+
+def test_preview_path_for_docx_generates_cache(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    original = storage_root / "original" / "sample.docx"
+    original.write_bytes(b"fake docx content")
+    document = FakeDocument("docx", "docxhash", "original/sample.docx")
+    path = preview_service.preview_path_for(document)
+    assert path.name == "docxhash.pdf"
+    assert path.exists()
+    assert path.read_bytes().startswith(b"%PDF-")
+
+
+def test_preview_path_for_doc_uses_cache_on_second_call(
+    preview_service: PreviewService, storage_root: Path
+) -> None:
+    original = storage_root / "original" / "sample.doc"
+    original.write_bytes(b"fake doc content")
+    document = FakeDocument("doc", "dochash", "original/sample.doc")
+    first = preview_service.preview_path_for(document)
+    mtime = first.stat().st_mtime
+    second = preview_service.preview_path_for(document)
+    assert second == first
+    assert second.stat().st_mtime == mtime

+ 1 - 1
backend/tests/dms/test_openapi_storage.py

@@ -115,7 +115,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/recycle-bin/documents",
         "/api/v1/recycle-bin/documents/{id}/restore",
     }
-    assert document["info"]["version"] == "1.5.0"
+    assert document["info"]["version"] == "1.6.0"
     assert "/api/v1/documents/{id}/restore" not in document["paths"]
     assert document["paths"]["/api/v1/health"]["get"]["security"] == []
     login_path = yaml.safe_load(

+ 1 - 1
backend/tests/dms/test_q1b_ui_dictionaries.py

@@ -284,7 +284,7 @@ def test_openapi_ui_dictionary_contract_and_no_write_operation():
         )
     )
 
-    assert document["info"]["version"] == "1.5.0"
+    assert document["info"]["version"] == "1.6.0"
     assert set(path_item) == {"get"}
     assert path_item["get"]["operationId"] == "getUiDictionaries"
     assert {"200", "401", "500"} <= set(path_item["get"]["responses"])

+ 1 - 1
backend/tests/dms/test_q2b_updated_range.py

@@ -344,7 +344,7 @@ def test_openapi_declares_q2b_parameters_without_new_paths():
         (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
     )
 
-    assert document["info"]["version"] == "1.5.0"
+    assert document["info"]["version"] == "1.6.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

+ 2 - 5
backend/tests/dms/test_q3b_business_content_preview.py

@@ -452,15 +452,12 @@ def test_q3_openapi_contract_has_no_new_preview_path():
     paths = yaml.safe_load(
         (root / "paths" / "documents.yaml").read_text("utf-8")
     )
-    assert document["info"]["version"] == "1.5.0"
+    assert document["info"]["version"] == "1.6.0"
     assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
     parameters = paths["Documents"]["get"]["parameters"]
     assert any(item.get("name") == "includeDescendants" for item in parameters)
     preview_content = paths["DocumentPreview"]["get"]["responses"]["200"][
         "content"
     ]
-    assert set(preview_content) == {
-        "application/pdf",
-        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-    }
+    assert set(preview_content) == {"application/pdf"}
     assert all("content-preview" not in path for path in document["paths"])

+ 23 - 21
frontend/src/features/files/FilePreviewModal.tsx

@@ -77,11 +77,8 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
       if (version !== requestVersion.current) return;
       const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase();
       const extension = item.fileExtension.trim().toLowerCase();
-      const expectedMime = extension === "pdf" ? PDF_MIME : extension === "docx" ? DOCX_MIME : null;
-      if (!expectedMime || mime !== expectedMime) {
-        throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: null, requestId: result.requestId });
-      }
-      if (mime === PDF_MIME) {
+      // 1.6 预览:PDF/DOC/DOCX 统一返回 application/pdf;XLS/XLSX 在后端返回 415
+      if ((extension === "pdf" || extension === "doc" || extension === "docx") && mime === PDF_MIME) {
         objectUrl = createPreviewUrl(result.blob);
         if (version !== requestVersion.current) {
           revokeObjectUrl(objectUrl);
@@ -90,21 +87,26 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
         setUrl(objectUrl);
         return;
       }
-      if (mime !== DOCX_MIME) {
-        throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: null, requestId: result.requestId });
+      if (extension === "docx") {
+        // 受控回退:仅当后端明确返回 DOCX 时使用 docx-preview
+        if (mime !== DOCX_MIME) {
+          throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { expected: DOCX_MIME, actual: mime }, requestId: result.requestId });
+        }
+        const buffer = await result.blob.arrayBuffer();
+        if (version !== requestVersion.current || !docxContainer.current) return;
+        docxContainer.current.replaceChildren();
+        await renderAsync(buffer, docxContainer.current, undefined, {
+          inWrapper: true,
+          ignoreWidth: mode === "side",
+          ignoreHeight: true,
+          breakPages: true,
+          useBase64URL: true,
+        });
+        if (version !== requestVersion.current || !docxContainer.current) return;
+        sanitizeRenderedContent(docxContainer.current);
+        return;
       }
-      const buffer = await result.blob.arrayBuffer();
-      if (version !== requestVersion.current || !docxContainer.current) return;
-      docxContainer.current.replaceChildren();
-      await renderAsync(buffer, docxContainer.current, undefined, {
-        inWrapper: true,
-        ignoreWidth: mode === "side",
-        ignoreHeight: true,
-        breakPages: true,
-        useBase64URL: true,
-      });
-      if (version !== requestVersion.current || !docxContainer.current) return;
-      sanitizeRenderedContent(docxContainer.current);
+      throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { extension, actual: mime }, requestId: result.requestId });
     }).catch((value: unknown) => {
       if (version === requestVersion.current) setError(previewError(value, responseRequestId));
     }).finally(() => {
@@ -146,9 +148,9 @@ export function FilePreviewModal({ item, onClose, mode = "side" }: {
         </div>
         <button aria-label="关闭预览" onClick={onClose}><X size={17} /></button>
       </div>
-      <div className="flex-1 min-h-0 overflow-auto relative" style={{ background: "#EBF4FD" }}>
+      <div className="flex-1 min-h-0 overflow-hidden relative" style={{ background: "#EBF4FD" }}>
         {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-sm font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
-        {url && <iframe title={`${item.documentName} PDF预览`} src={url} className="w-full h-full min-h-[560px] border-0" />}
+        {url && <iframe title={`${item.documentName} PDF预览`} src={`${url}#zoom=125`} className="w-full h-full border-0" />}
         <div ref={docxContainer} className={`q3-docx-preview min-h-full overflow-auto [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`} />
         {error && <div className="p-8 text-center" style={{ color: "#1A5299" }}>
           <FileWarning size={38} className="mx-auto mb-3 opacity-60" />