510 lines
19 KiB
Python
510 lines
19 KiB
Python
import asyncio
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import uuid
|
||
from datetime import datetime
|
||
from io import BytesIO
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||
from sqlalchemy import delete, func, 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 models.template_block import TemplateBlock
|
||
from schemas.schemas import Response, TemplateSave
|
||
from services.minio_client import minio_client
|
||
from services.template_parser import parse_template
|
||
from services.word_to_html import docx_to_full_html, docx_to_html_blocks
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _build_object_path(filename: str) -> tuple[str, str]:
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
date_prefix = datetime.now().strftime("%Y%m%d")
|
||
object_name = f"{date_prefix}/{uuid.uuid4().hex}{ext}"
|
||
return ext, object_name
|
||
|
||
|
||
def _serialize_paragraph(paragraph: Paragraph) -> dict:
|
||
return {
|
||
"id": paragraph.id,
|
||
"template_id": paragraph.template_id,
|
||
"sort_index": paragraph.sort_index,
|
||
"anchor_title": paragraph.anchor_title,
|
||
"title": paragraph.title,
|
||
"content": paragraph.content,
|
||
"style_json": paragraph.style_json,
|
||
"is_table": paragraph.is_table,
|
||
"table_json": paragraph.table_json,
|
||
"edit_mode": paragraph.edit_mode,
|
||
"write_mode": paragraph.write_mode,
|
||
"model_id": paragraph.model_id,
|
||
"need_prompt": paragraph.need_prompt,
|
||
"prompt_text": paragraph.prompt_text,
|
||
"need_file": paragraph.need_file,
|
||
"file_note": paragraph.file_note,
|
||
"output_format": paragraph.output_format,
|
||
}
|
||
|
||
|
||
def _serialize_template(template: Template) -> dict:
|
||
return {
|
||
"id": template.id,
|
||
"name": template.name,
|
||
"description": template.description,
|
||
"file_path": template.file_path,
|
||
"paragraph_count": template.paragraph_count,
|
||
"status": template.status,
|
||
"created_at": template.created_at,
|
||
"updated_at": template.updated_at,
|
||
}
|
||
|
||
|
||
def _serialize_block(block: TemplateBlock) -> dict:
|
||
return {
|
||
"id": block.id,
|
||
"template_id": block.template_id,
|
||
"sort_index": block.sort_index,
|
||
"block_type": block.block_type,
|
||
"anchor_ref": block.anchor_ref,
|
||
"anchor_start_index": block.anchor_start_index,
|
||
"anchor_end_index": block.anchor_end_index,
|
||
"html_snippet": block.html_snippet,
|
||
"title": block.title,
|
||
"content_json": block.content_json,
|
||
"style_json": block.style_json,
|
||
"edit_mode": block.edit_mode,
|
||
"placeholder_key": block.placeholder_key,
|
||
"variable_key": block.variable_key,
|
||
"default_value": block.default_value,
|
||
"model_id": block.model_id,
|
||
"need_prompt": block.need_prompt,
|
||
"prompt_text": block.prompt_text,
|
||
"need_file": block.need_file,
|
||
"file_note": block.file_note,
|
||
"output_format": block.output_format,
|
||
}
|
||
|
||
|
||
@router.get("")
|
||
async def list_templates(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
keyword: str = Query("", alias="q"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
filters = []
|
||
if keyword:
|
||
filters.append(Template.name.like(f"%{keyword}%"))
|
||
|
||
total_stmt = select(func.count(Template.id))
|
||
list_stmt = select(Template).order_by(Template.id.desc())
|
||
if filters:
|
||
total_stmt = total_stmt.where(*filters)
|
||
list_stmt = list_stmt.where(*filters)
|
||
|
||
total = (await db.execute(total_stmt)).scalar_one()
|
||
result = await db.execute(list_stmt.offset((page - 1) * page_size).limit(page_size))
|
||
items = [_serialize_template(item) for item in result.scalars().all()]
|
||
return Response(
|
||
data={"items": items, "total": total, "page": page, "page_size": page_size}
|
||
)
|
||
|
||
|
||
@router.get("/{template_id}")
|
||
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||
template = await db.get(Template, template_id)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
result = await db.execute(
|
||
select(Paragraph)
|
||
.where(Paragraph.template_id == template_id)
|
||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||
)
|
||
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||
payload = _serialize_template(template)
|
||
payload["paragraphs"] = paragraphs
|
||
return Response(data=payload)
|
||
|
||
|
||
@router.get("/{template_id}/preview")
|
||
async def get_template_preview(template_id: int, db: AsyncSession = Depends(get_db)):
|
||
"""返回模板的 HTML 预览文档及块列表。"""
|
||
template = await db.get(Template, template_id)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
# 优先从缓存读取 HTML,否则重新生成
|
||
result = await db.execute(
|
||
select(TemplateBlock)
|
||
.where(TemplateBlock.template_id == template_id)
|
||
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
|
||
)
|
||
blocks = result.scalars().all()
|
||
|
||
if not blocks:
|
||
# 从 MinIO 下载模板文件,重新生成 HTML 和块
|
||
file_path = template.file_path or ""
|
||
if "/" in file_path:
|
||
bucket, object_name = file_path.split("/", 1)
|
||
content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
|
||
try:
|
||
file_bytes = content.read()
|
||
finally:
|
||
content.close()
|
||
content.release_conn()
|
||
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
|
||
temp_file.write(file_bytes)
|
||
temp_path = temp_file.name
|
||
|
||
try:
|
||
rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
|
||
html_content = await asyncio.to_thread(docx_to_full_html, temp_path)
|
||
finally:
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
|
||
block_rows: list[TemplateBlock] = []
|
||
for rb in rendered_blocks:
|
||
anchor_title = ""
|
||
if rb.block_type == "heading":
|
||
anchor_title = rb.text_content
|
||
block_row = TemplateBlock(
|
||
template_id=template_id,
|
||
sort_index=rb.block_index + 1,
|
||
block_type=rb.block_type,
|
||
anchor_ref=anchor_title,
|
||
anchor_start_index=rb.block_index,
|
||
anchor_end_index=rb.block_index,
|
||
html_snippet=rb.html,
|
||
title=rb.text_content[:200] if rb.text_content else "",
|
||
content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
|
||
style_json=rb.style_json,
|
||
edit_mode="manual",
|
||
)
|
||
db.add(block_row)
|
||
block_rows.append(block_row)
|
||
await db.commit()
|
||
for br in block_rows:
|
||
await db.refresh(br)
|
||
blocks = block_rows
|
||
|
||
block_list = [_serialize_block(b) for b in blocks]
|
||
|
||
# 检查是否有缓存的 HTML 快照
|
||
html_snapshot = ""
|
||
for b in blocks:
|
||
if b.html_snippet:
|
||
html_snapshot += b.html_snippet + "\n"
|
||
|
||
if not html_snapshot:
|
||
# 重新生成 HTML
|
||
file_path = template.file_path or ""
|
||
if "/" in file_path:
|
||
bucket, object_name = file_path.split("/", 1)
|
||
content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
|
||
try:
|
||
file_bytes = content.read()
|
||
finally:
|
||
content.close()
|
||
content.release_conn()
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
|
||
temp_file.write(file_bytes)
|
||
temp_path = temp_file.name
|
||
try:
|
||
html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
|
||
finally:
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
|
||
return Response(data={
|
||
"template": _serialize_template(template),
|
||
"blocks": block_list,
|
||
"html": html_snapshot,
|
||
"block_count": len(block_list),
|
||
})
|
||
|
||
|
||
@router.post("/upload")
|
||
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||
if not file.filename:
|
||
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||
|
||
ext, object_name = _build_object_path(file.filename)
|
||
if ext != ".docx":
|
||
raise HTTPException(status_code=400, detail="模板仅支持 .docx 格式")
|
||
|
||
content = await file.read()
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
|
||
temp_file.write(content)
|
||
temp_path = temp_file.name
|
||
|
||
try:
|
||
parsed_items = await asyncio.to_thread(parse_template, temp_path)
|
||
rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
|
||
html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
|
||
finally:
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
|
||
await asyncio.to_thread(
|
||
minio_client.put_object,
|
||
settings.MINIO_BUCKET_TEMPLATES,
|
||
object_name,
|
||
BytesIO(content),
|
||
len(content),
|
||
file.content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
)
|
||
|
||
template = Template(
|
||
name=os.path.splitext(file.filename)[0],
|
||
description="",
|
||
file_path=f"{settings.MINIO_BUCKET_TEMPLATES}/{object_name}",
|
||
paragraph_count=len(parsed_items),
|
||
status="draft",
|
||
)
|
||
db.add(template)
|
||
await db.flush()
|
||
|
||
paragraph_rows: list[Paragraph] = []
|
||
for item in parsed_items:
|
||
paragraph = Paragraph(
|
||
template_id=template.id,
|
||
sort_index=item.sort_index,
|
||
anchor_title=item.anchor_title,
|
||
title=item.title,
|
||
content=item.content,
|
||
style_json=item.style_json,
|
||
is_table=item.is_table,
|
||
table_json=item.table_json,
|
||
edit_mode="manual",
|
||
write_mode=item.write_mode,
|
||
)
|
||
db.add(paragraph)
|
||
paragraph_rows.append(paragraph)
|
||
|
||
# 同步生成新版块结构
|
||
block_rows: list[TemplateBlock] = []
|
||
for rb in rendered_blocks:
|
||
anchor_title = rb.text_content if rb.block_type == "heading" else ""
|
||
block_row = TemplateBlock(
|
||
template_id=template.id,
|
||
sort_index=rb.block_index + 1,
|
||
block_type=rb.block_type,
|
||
anchor_ref=anchor_title,
|
||
anchor_start_index=rb.block_index,
|
||
anchor_end_index=rb.block_index,
|
||
html_snippet=rb.html,
|
||
title=rb.text_content[:200] if rb.text_content else "",
|
||
content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
|
||
style_json=rb.style_json,
|
||
edit_mode="manual",
|
||
)
|
||
db.add(block_row)
|
||
block_rows.append(block_row)
|
||
|
||
await db.commit()
|
||
await db.refresh(template)
|
||
for paragraph in paragraph_rows:
|
||
await db.refresh(paragraph)
|
||
for br in block_rows:
|
||
await db.refresh(br)
|
||
|
||
payload = _serialize_template(template)
|
||
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||
payload["blocks"] = [_serialize_block(b) for b in block_rows]
|
||
payload["html"] = html_snapshot
|
||
return Response(data=payload)
|
||
|
||
|
||
@router.put("/{template_id}/paragraphs")
|
||
async def save_template_paragraphs(
|
||
template_id: int,
|
||
body: TemplateSave,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
template = await db.get(Template, template_id)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
result = await db.execute(
|
||
select(Paragraph)
|
||
.where(Paragraph.template_id == template_id)
|
||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||
)
|
||
existing_paragraphs = result.scalars().all()
|
||
paragraph_map = {item.id: item for item in existing_paragraphs}
|
||
incoming_ids = {config.id for config in body.paragraphs if config.id}
|
||
|
||
print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}")
|
||
|
||
for paragraph in existing_paragraphs:
|
||
if paragraph.id not in incoming_ids:
|
||
print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}")
|
||
await db.execute(
|
||
delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)
|
||
)
|
||
await db.delete(paragraph)
|
||
|
||
for index, config in enumerate(body.paragraphs, start=1):
|
||
paragraph = paragraph_map.get(config.id) if config.id else None
|
||
if paragraph is None:
|
||
paragraph = Paragraph(template_id=template_id)
|
||
db.add(paragraph)
|
||
paragraph.sort_index = index
|
||
paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
|
||
paragraph.title = config.title
|
||
paragraph.content = config.content
|
||
paragraph.edit_mode = config.edit_mode
|
||
paragraph.write_mode = config.write_mode
|
||
paragraph.model_id = config.model_id
|
||
paragraph.need_prompt = config.need_prompt
|
||
paragraph.prompt_text = config.prompt_text
|
||
paragraph.need_file = config.need_file
|
||
paragraph.file_note = config.file_note
|
||
paragraph.output_format = config.output_format
|
||
|
||
await db.commit()
|
||
template.paragraph_count = len(body.paragraphs)
|
||
await db.commit()
|
||
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||
|
||
|
||
@router.put("/{template_id}/blocks")
|
||
async def save_template_blocks(
|
||
template_id: int,
|
||
body: dict,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""保存模板的块配置(可视化编辑器使用)。"""
|
||
template = await db.get(Template, template_id)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
block_data = body.get("blocks", [])
|
||
if not block_data:
|
||
raise HTTPException(status_code=400, detail="块数据不能为空")
|
||
|
||
result = await db.execute(
|
||
select(TemplateBlock)
|
||
.where(TemplateBlock.template_id == template_id)
|
||
)
|
||
existing_blocks = result.scalars().all()
|
||
block_map = {b.id: b for b in existing_blocks}
|
||
incoming_ids = {b.get("id") for b in block_data if b.get("id")}
|
||
|
||
# 删除被移除的块
|
||
for block in existing_blocks:
|
||
if block.id not in incoming_ids:
|
||
await db.delete(block)
|
||
|
||
# 新增 / 更新块
|
||
for index, config in enumerate(block_data, start=1):
|
||
block = block_map.get(config.get("id")) if config.get("id") else None
|
||
if block is None:
|
||
block = TemplateBlock(template_id=template_id)
|
||
db.add(block)
|
||
block.sort_index = index
|
||
block.block_type = config.get("block_type", block.block_type)
|
||
block.anchor_ref = config.get("anchor_ref", block.anchor_ref)
|
||
block.anchor_start_index = config.get("anchor_start_index", block.anchor_start_index)
|
||
block.anchor_end_index = config.get("anchor_end_index", block.anchor_end_index)
|
||
block.title = config.get("title", block.title)
|
||
block.content_json = config.get("content_json", block.content_json)
|
||
block.style_json = config.get("style_json", block.style_json)
|
||
block.edit_mode = config.get("edit_mode", block.edit_mode)
|
||
block.model_id = config.get("model_id", block.model_id)
|
||
block.need_prompt = config.get("need_prompt", block.need_prompt)
|
||
block.prompt_text = config.get("prompt_text", block.prompt_text)
|
||
block.need_file = config.get("need_file", block.need_file)
|
||
block.file_note = config.get("file_note", block.file_note)
|
||
block.output_format = config.get("output_format", block.output_format)
|
||
|
||
await db.commit()
|
||
|
||
# 更新每个块的 html_snippet 加入对应的 CSS class
|
||
try:
|
||
result = await db.execute(
|
||
select(TemplateBlock)
|
||
.where(TemplateBlock.template_id == template_id)
|
||
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
|
||
)
|
||
updated_blocks = result.scalars().all()
|
||
for b in updated_blocks:
|
||
if b.html_snippet and b.edit_mode == "ai":
|
||
b.html_snippet = b.html_snippet.replace(
|
||
'data-block-index=',
|
||
'class="block-ai" data-block-index=',
|
||
1,
|
||
)
|
||
elif b.html_snippet and b.edit_mode == "manual":
|
||
b.html_snippet = b.html_snippet.replace(
|
||
'data-block-index=',
|
||
'class="block-fixed" data-block-index=',
|
||
1,
|
||
)
|
||
await db.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
return Response(data={"template_id": template_id, "saved": len(block_data)})
|
||
|
||
|
||
@router.delete("/{template_id}")
|
||
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||
template = await db.get(Template, template_id)
|
||
if template is None:
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||
paragraphs_to_delete = result.scalars().all()
|
||
paragraph_ids = [p.id for p in paragraphs_to_delete]
|
||
|
||
blocks_result = await db.execute(select(TemplateBlock).where(TemplateBlock.template_id == template_id))
|
||
blocks_to_delete = blocks_result.scalars().all()
|
||
|
||
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
|
||
documents_to_delete = doc_result.scalars().all()
|
||
|
||
if paragraph_ids:
|
||
await db.execute(
|
||
delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids))
|
||
)
|
||
for document in documents_to_delete:
|
||
await db.execute(
|
||
delete(GenerationLog).where(GenerationLog.document_id == document.id)
|
||
)
|
||
await db.delete(document)
|
||
|
||
for paragraph in paragraphs_to_delete:
|
||
await db.delete(paragraph)
|
||
|
||
for block in blocks_to_delete:
|
||
await db.delete(block)
|
||
|
||
file_path = template.file_path or ""
|
||
if "/" in file_path:
|
||
bucket, object_name = file_path.split("/", 1)
|
||
try:
|
||
await asyncio.to_thread(minio_client.remove_object, bucket, object_name)
|
||
except Exception:
|
||
pass
|
||
|
||
await db.delete(template)
|
||
await db.commit()
|
||
return Response(data={"id": template_id})
|