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})