补充运行说明并实现基础导出能力

This commit is contained in:
zwt13703
2026-07-02 15:13:57 +08:00
parent a2bad58591
commit b313083766
7 changed files with 414 additions and 57 deletions
+66 -6
View File
@@ -1,14 +1,74 @@
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
import asyncio
import json
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from services.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
get_presigned_url,
split_bucket_path,
upload_bytes,
)
router = APIRouter()
@router.get("/{document_id}/docx")
async def export_docx(document_id: int):
return PlainTextResponse(
f"文档 {document_id} 的 Word 导出功能正在开发中,当前版本请先使用预览页查看结果。",
media_type="text/plain; charset=utf-8",
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
template = await db.get(Template, document.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
logs = []
for log, paragraph in result.all():
logs.append(
{
"title": paragraph.title,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
await asyncio.to_thread(
upload_bytes,
settings.MINIO_BUCKET_OUTPUTS,
object_name,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
await db.commit()
return RedirectResponse(
url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
status_code=307,
)
+141
View File
@@ -0,0 +1,141 @@
from io import BytesIO
from docx import Document
from docx.document import Document as DocumentObject
from docx.oxml import OxmlElement
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table, _Cell
from docx.text.paragraph import Paragraph
def _iter_block_items(parent: DocumentObject | _Cell):
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
for child in parent_elm.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)
def _is_heading(paragraph: Paragraph) -> bool:
style_name = paragraph.style.name if paragraph.style is not None else ""
normalized = style_name.lower().replace(" ", "")
return normalized.startswith("heading")
def _delete_block(block):
element = block._element
parent = element.getparent()
if parent is not None:
parent.remove(element)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
if child.tag.endswith("}r"):
element.remove(child)
def _append_paragraph_after(paragraph: Paragraph, text: str, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
paragraph._element.addnext(new_p)
new_para = Paragraph(new_p, paragraph._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
if text:
new_para.add_run(text)
return new_para
def _append_table_after(paragraph: Paragraph, rows: list[list[str]], headers: list[str] | None = None):
container = paragraph._parent
table = container.add_table(rows=1, cols=max(len(headers or []), len(rows[0]) if rows else 1))
if headers:
header_cells = table.rows[0].cells
for index, value in enumerate(headers):
header_cells[index].text = value
else:
if rows:
first = rows.pop(0)
for index, value in enumerate(first):
table.rows[0].cells[index].text = value
for row in rows:
new_row = table.add_row().cells
for index, value in enumerate(row):
new_row[index].text = value
tbl = table._tbl
tbl.getparent().remove(tbl)
paragraph._element.addnext(tbl)
return Table(tbl, container)
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
table._tbl.addnext(new_p)
new_para = Paragraph(new_p, table._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
return new_para
def _find_heading_paragraph(document: DocumentObject, heading_text: str) -> Paragraph | None:
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
return block
return None
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict):
heading = _find_heading_paragraph(document, heading_title)
if heading is None:
return
first_body_style = None
current = heading._element.getnext()
blocks_to_remove = []
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
if _is_heading(current_paragraph):
break
if first_body_style is None and current_paragraph.style is not None:
first_body_style = current_paragraph.style.name
blocks_to_remove.append(current_paragraph)
elif isinstance(current, CT_Tbl):
blocks_to_remove.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks_to_remove:
_delete_block(block)
insert_after = heading
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
table = _append_table_after(insert_after, rows, headers)
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
insert_after = _append_paragraph_after(insert_after, text, first_body_style)
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
for item in logs:
_replace_section_content(document, item["title"], item["content"])
output = BytesIO()
document.save(output)
return output.getvalue()
+34 -1
View File
@@ -1,3 +1,6 @@
import io
from datetime import timedelta
from minio import Minio
from config import settings
@@ -34,4 +37,34 @@ def get_file_url(bucket: str, object_name: str) -> str:
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
"""获取预签名下载 URL(带过期时间)"""
return minio_client.presigned_get_object(bucket, object_name, expires=expires)
return minio_client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires))
def split_bucket_path(file_path: str) -> tuple[str, str]:
if "/" not in file_path:
raise ValueError("非法的 MinIO 文件路径")
return file_path.split("/", 1)
def download_object_bytes(bucket: str, object_name: str) -> bytes:
response = minio_client.get_object(bucket, object_name)
try:
return response.read()
finally:
response.close()
response.release_conn()
def upload_bytes(
bucket: str,
object_name: str,
content: bytes,
content_type: str = "application/octet-stream",
):
minio_client.put_object(
bucket,
object_name,
io.BytesIO(content),
len(content),
content_type=content_type,
)