fix(qa_expert): 修复 Markdown 附件水印下载 500 (#2363)

## Summary
- 修复专家问答预览里下载 `.md` 附件时后端 500、前端报「网络请求失败」
- 根因:文本附件误走只接受 `.doc/.docx` 的 `convert_docx_to_pdf`,返回 `False` 后继续读路径崩溃
- `.md/.txt/.html` 优先走统一 PDF 转换器,失败回退 fitz 纯文本 PDF;Office 走 LibreOffice
转换器

## Test plan
- [x] `uv run pytest test/qa_expert/test_watermarked_download.py -q`
- [ ] 测试环境专家问答详情:预览 Markdown 附件并点击下载,应得到带水印 PDF


Made with [Cursor](https://cursor.com)
This commit is contained in:
Mr.kai
2026-08-24 02:08:33 -07:00
committed by GitHub
2 changed files with 173 additions and 12 deletions
@@ -126,6 +126,107 @@ def _image_bytes_to_pdf(data: bytes, image_type: str | None) -> bytes:
src.close()
def _decode_attachment_text(data: bytes) -> str:
"""解码问答附件文本;无法严格解码时用 replace,避免整条下载失败。"""
for encoding in ("utf-8-sig", "utf-16", "utf-8"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
def _plain_text_to_pdf(data: bytes) -> bytes:
"""不依赖 LibreOffice/Playwright:用 CJK 字体把纯文本按 A4 分页写入 PDF。
`.md` / `.html` 按原文落 PDF(不做富文本排版);保证水印下载链路始终可用。
"""
from bisheng.knowledge.pdf.watermark import PdfWatermarkError, _resolve_cjk_font
text = _decode_attachment_text(data).replace("\r\n", "\n").replace("\r", "\n")
if not text.strip():
text = " "
try:
font_sel = _resolve_cjk_font()
except PdfWatermarkError as exc:
raise QaWatermarkDownloadError("CJK font unavailable for text watermarked download") from exc
font = fitz.Font(fontfile=font_sel.font_file, fontname=font_sel.font_name)
page_width, page_height = 595.0, 842.0
margin = 48.0
fontsize = 11.0
line_height = fontsize * 1.45
max_width = page_width - margin * 2
def iter_wrapped_lines() -> list[str]:
lines: list[str] = []
for paragraph in text.split("\n"):
if not paragraph:
lines.append("")
continue
buf = ""
for ch in paragraph:
trial = buf + ch
if font.text_length(trial, fontsize=fontsize) <= max_width:
buf = trial
continue
if buf:
lines.append(buf)
buf = ch
lines.append(buf)
return lines
wrapped = iter_wrapped_lines()
doc = fitz.open()
try:
y = margin
page = doc.new_page(width=page_width, height=page_height)
for line in wrapped:
if y + line_height > page_height - margin:
page = doc.new_page(width=page_width, height=page_height)
y = margin
page.insert_text(
(margin, y + fontsize),
line or " ",
fontsize=fontsize,
fontfile=font_sel.font_file,
fontname=font_sel.font_name,
)
y += line_height
return doc.tobytes()
finally:
doc.close()
def _convert_via_pdf_registry(data: bytes, suffix: str) -> bytes:
"""走知识库统一转换器(Office→LibreOfficemd/txt/html→Playwright)。"""
from bisheng.knowledge.pdf.converter import (
ConversionContext,
PdfConversionError,
PdfConverterRegistry,
)
normalized = ".html" if suffix == ".htm" else suffix
with tempfile.TemporaryDirectory(prefix="qa-wm-") as tmp:
tmp_path = Path(tmp)
src_path = tmp_path / f"source{normalized or '.bin'}"
src_path.write_bytes(data)
try:
result = PdfConverterRegistry().convert(
src_path,
tmp_path / "out",
ConversionContext(timeout_seconds=120),
)
except PdfConversionError as exc:
raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download") from exc
if result.converter == "original-pdf":
return data
pdf_path = Path(result.pdf_path)
if not pdf_path.is_file() or pdf_path.stat().st_size <= 0:
raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download")
return pdf_path.read_bytes()
def _bytes_to_pdf(data: bytes, filename: str) -> bytes:
suffix = Path(filename).suffix.lower()
if data[:5] == b"%PDF-" or suffix == ".pdf":
@@ -135,24 +236,41 @@ def _bytes_to_pdf(data: bytes, filename: str) -> bytes:
if image_type or suffix in _IMAGE_SUFFIXES:
return _image_bytes_to_pdf(data, image_type)
office_suffixes = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".et", ".wps", ".dps"}
if suffix in office_suffixes or suffix in {".txt", ".md", ".csv", ".html", ".htm"}:
from bisheng.knowledge.rag.pipeline.loader.utils.libreoffice_converter import (
convert_docx_to_pdf,
convert_ppt_to_pdf,
# 文本类:优先 Chromium 排版;失败则退回 fitz 纯文本(修复误用 convert_docx_to_pdf 导致 .md 500
text_suffixes = {".txt", ".md", ".html", ".htm"}
if suffix in text_suffixes:
try:
return _convert_via_pdf_registry(data, suffix)
except QaWatermarkDownloadError as exc:
logger.info("QA text/web PDF converter unavailable, fallback to plain text: {}", exc)
return _plain_text_to_pdf(data)
office_suffixes = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"}
if suffix in office_suffixes:
return _convert_via_pdf_registry(data, suffix)
# 国产 Office 后缀:LibreOffice 常可转,但不在 PdfConverterRegistry 白名单内
legacy_office_suffixes = {".et", ".wps", ".dps"}
if suffix in legacy_office_suffixes:
from bisheng.knowledge.pdf.converter import (
ConversionContext,
OfficePdfConverter,
PdfConversionError,
)
with tempfile.TemporaryDirectory(prefix="qa-wm-") as tmp:
src_path = Path(tmp) / f"source{suffix or '.bin'}"
tmp_path = Path(tmp)
src_path = tmp_path / f"source{suffix}"
src_path.write_bytes(data)
try:
if suffix in {".ppt", ".pptx", ".dps"}:
pdf_path = convert_ppt_to_pdf(str(src_path), tmp)
else:
pdf_path = convert_docx_to_pdf(str(src_path), tmp)
except Exception as exc:
result = OfficePdfConverter().convert(
src_path,
tmp_path / "out",
ConversionContext(timeout_seconds=120),
)
except PdfConversionError as exc:
raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download") from exc
return Path(pdf_path).read_bytes()
return Path(result.pdf_path).read_bytes()
raise QaWatermarkDownloadError("unsupported attachment type for watermarked download")
@@ -3,6 +3,7 @@ from io import BytesIO
from PIL import Image
from bisheng.qa_expert.domain.watermarked_download import (
QaWatermarkDownloadError,
_bytes_to_pdf,
parse_qa_asset_location,
resolve_conversion_filename,
@@ -65,3 +66,45 @@ def test_bytes_to_pdf_converts_webp_via_pillow_fallback():
# 详情页标题无后缀时,靠 sniff + Pillow
pdf2 = _bytes_to_pdf(webp, "问题图片 1")
assert pdf2[:5] == b"%PDF-"
def test_bytes_to_pdf_converts_markdown_without_docx_converter(monkeypatch):
"""`.md` 不得再误走 convert_docx_to_pdf(其只接受 doc/docx,会返回 False 并 500)。"""
calls: list[str] = []
def _forbid_docx(*_args, **_kwargs):
calls.append("docx")
raise AssertionError("must not call convert_docx_to_pdf for markdown")
monkeypatch.setattr(
"bisheng.knowledge.rag.pipeline.loader.utils.libreoffice_converter.convert_docx_to_pdf",
_forbid_docx,
raising=False,
)
# 强制走纯文本回退,避免单测依赖本机 Playwright/Chromium
def _fail_registry(*_args, **_kwargs):
raise QaWatermarkDownloadError("playwright unavailable in unit test")
monkeypatch.setattr(
"bisheng.qa_expert.domain.watermarked_download._convert_via_pdf_registry",
_fail_registry,
)
md = "# 标题\n\n工作流与智能体功能清单\n".encode()
pdf = _bytes_to_pdf(md, "工作流与智能体功能清单-实现方案.md")
assert pdf[:5] == b"%PDF-"
assert calls == []
def test_bytes_to_pdf_converts_plain_text_via_fallback(monkeypatch):
def _fail_registry(*_args, **_kwargs):
raise QaWatermarkDownloadError("no chromium")
monkeypatch.setattr(
"bisheng.qa_expert.domain.watermarked_download._convert_via_pdf_registry",
_fail_registry,
)
pdf = _bytes_to_pdf("hello\n第二行".encode(), "note.txt")
assert pdf[:5] == b"%PDF-"