diff --git a/backend/app/api/generation_points.py b/backend/app/api/generation_points.py new file mode 100644 index 0000000..8f60e50 --- /dev/null +++ b/backend/app/api/generation_points.py @@ -0,0 +1,120 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.models.generation_point import GenerationPoint +from app.models.template import Template +from app.models.ai_model import AIModel +from app.schemas.generation_point import ( + GenerationPointCreate, + GenerationPointUpdate, + GenerationPointResponse, + BatchOrderUpdate, +) +from app.services.file_storage import save_upload, REF_FILES_DIR + +router = APIRouter(prefix="/generation-points", tags=["生成点管理"]) + + +@router.post("", response_model=GenerationPointResponse) +async def create_generation_point( + template_id: uuid.UUID = Form(...), + position: str = Form(...), + prompt: str = Form(...), + model_id: uuid.UUID | None = Form(None), + order: int = Form(0), + ref_file: UploadFile | None = File(None), + db: AsyncSession = Depends(get_db), +): + import json + + template_result = await db.execute(select(Template).where(Template.id == template_id)) + if not template_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="模板不存在") + + if model_id: + model_result = await db.execute(select(AIModel).where(AIModel.id == model_id)) + if not model_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="模型不存在") + + ref_file_path = None + if ref_file: + ref_file_path = await save_upload(ref_file, REF_FILES_DIR) + + point = GenerationPoint( + template_id=template_id, + position=json.loads(position), + prompt=prompt, + model_id=model_id, + order=order, + ref_file_path=ref_file_path, + ) + db.add(point) + await db.flush() + await db.refresh(point) + return point + + +@router.get("", response_model=list[GenerationPointResponse]) +async def list_generation_points( + template_id: uuid.UUID = Query(...), + db: AsyncSession = Depends(get_db), +): + query = ( + select(GenerationPoint) + .where(GenerationPoint.template_id == template_id) + .order_by(GenerationPoint.order.asc(), GenerationPoint.created_at.asc()) + ) + result = await db.execute(query) + return result.scalars().all() + + +@router.get("/{point_id}", response_model=GenerationPointResponse) +async def get_generation_point(point_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id)) + point = result.scalar_one_or_none() + if not point: + raise HTTPException(status_code=404, detail="生成点不存在") + return point + + +@router.put("/{point_id}", response_model=GenerationPointResponse) +async def update_generation_point( + point_id: str, + data: GenerationPointUpdate, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id)) + point = result.scalar_one_or_none() + if not point: + raise HTTPException(status_code=404, detail="生成点不存在") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(point, key, value) + + await db.flush() + await db.refresh(point) + return point + + +@router.delete("/{point_id}") +async def delete_generation_point(point_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id)) + point = result.scalar_one_or_none() + if not point: + raise HTTPException(status_code=404, detail="生成点不存在") + await db.delete(point) + return {"detail": "删除成功"} + + +@router.post("/batch-order") +async def batch_update_order(data: BatchOrderUpdate, db: AsyncSession = Depends(get_db)): + for item in data.points: + result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == item["id"])) + point = result.scalar_one_or_none() + if point: + point.order = item["order"] + await db.flush() + return {"detail": "排序更新成功"} diff --git a/backend/app/api/models.py b/backend/app/api/models.py new file mode 100644 index 0000000..0fcf2f9 --- /dev/null +++ b/backend/app/api/models.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.core.security import encrypt_api_key +from app.models.ai_model import AIModel +from app.schemas.ai_model import AIModelCreate, AIModelUpdate, AIModelToggle, AIModelResponse + +router = APIRouter(prefix="/models", tags=["AI模型管理"]) + + +@router.post("", response_model=AIModelResponse) +async def create_model(data: AIModelCreate, db: AsyncSession = Depends(get_db)): + encrypted_key = encrypt_api_key(data.api_key) + model = AIModel( + name=data.name, + provider=data.provider, + endpoint=data.endpoint, + api_key=encrypted_key, + extra_params=data.extra_params, + is_enabled=data.is_enabled, + remark=data.remark, + ) + db.add(model) + await db.flush() + await db.refresh(model) + model.api_key = "***" + return model + + +@router.get("", response_model=list[AIModelResponse]) +async def list_models( + enabled: bool | None = Query(None, description="过滤启用/禁用"), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +): + query = select(AIModel) + if enabled is not None: + query = query.where(AIModel.is_enabled == enabled) + query = query.offset(skip).limit(limit).order_by(AIModel.created_at.desc()) + result = await db.execute(query) + models = result.scalars().all() + for m in models: + m.api_key = "***" + return models + + +@router.get("/{model_id}", response_model=AIModelResponse) +async def get_model(model_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(AIModel).where(AIModel.id == model_id)) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + model.api_key = "***" + return model + + +@router.put("/{model_id}", response_model=AIModelResponse) +async def update_model(model_id: str, data: AIModelUpdate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(AIModel).where(AIModel.id == model_id)) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + + update_data = data.model_dump(exclude_unset=True) + if "api_key" in update_data and update_data["api_key"] is not None: + update_data["api_key"] = encrypt_api_key(update_data["api_key"]) + + for key, value in update_data.items(): + setattr(model, key, value) + + await db.flush() + await db.refresh(model) + model.api_key = "***" + return model + + +@router.delete("/{model_id}") +async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(AIModel).where(AIModel.id == model_id)) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + await db.delete(model) + return {"detail": "删除成功"} + + +@router.patch("/{model_id}/toggle", response_model=AIModelResponse) +async def toggle_model(model_id: str, data: AIModelToggle, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(AIModel).where(AIModel.id == model_id)) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + model.is_enabled = data.is_enabled + await db.flush() + await db.refresh(model) + model.api_key = "***" + return model diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..84c67ee --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from app.api.models import router as models_router +from app.api.templates import router as templates_router +from app.api.generation_points import router as generation_points_router +from app.api.tasks import router as tasks_router +from app.core.config import get_settings + +settings = get_settings() + +api_router = APIRouter(prefix=settings.API_V1_PREFIX) +api_router.include_router(models_router) +api_router.include_router(templates_router) +api_router.include_router(generation_points_router) +api_router.include_router(tasks_router) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..4fe82c7 --- /dev/null +++ b/backend/app/api/tasks.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/tasks", tags=["生成任务"]) + +# 任务管理 API 将在阶段三实现 diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py new file mode 100644 index 0000000..5c085ab --- /dev/null +++ b/backend/app/api/templates.py @@ -0,0 +1,126 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.models.template import Template +from app.schemas.template import TemplateResponse, TemplateListItem, HTMLContentResponse, HTMLUpdateRequest +from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR +from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes + +router = APIRouter(prefix="/templates", tags=["模板管理"]) + + +@router.post("", response_model=TemplateResponse) +async def upload_template( + file: UploadFile = File(...), + name: str | None = Form(None), + db: AsyncSession = Depends(get_db), +): + if not file.filename or not file.filename.endswith(".docx"): + raise HTTPException(status_code=400, detail="仅支持 .docx 文件") + + file_path = await save_upload(file, TEMPLATES_DIR) + file_content = await get_file_content(file_path) + html_content = await docx_to_html(file_content) + + template = Template( + name=name or file.filename.replace(".docx", ""), + file_path=file_path, + html_content=html_content, + ) + db.add(template) + await db.flush() + await db.refresh(template) + return template + + +@router.get("", response_model=list[TemplateListItem]) +async def list_templates( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +): + query = select(Template).offset(skip).limit(limit).order_by(Template.created_at.desc()) + result = await db.execute(query) + return result.scalars().all() + + +@router.get("/{template_id}", response_model=TemplateResponse) +async def get_template(template_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Template).where(Template.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.get("/{template_id}/html", response_model=HTMLContentResponse) +async def get_template_html(template_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Template).where(Template.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return HTMLContentResponse(html_content=template.html_content or "") + + +@router.put("/{template_id}/html") +async def update_template_html( + template_id: str, + data: HTMLUpdateRequest, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Template).where(Template.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + template.html_content = data.html_content + + docx_bytes = html_to_docx_bytes(data.html_content) + with open(template.file_path, "wb") as f: + f.write(docx_bytes) + + await db.flush() + return {"detail": "保存成功"} + + +@router.get("/{template_id}/download") +async def download_template( + template_id: str, + format: str = Query("docx", pattern="^(docx|pdf)$"), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Template).where(Template.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + file_content = await get_file_content(template.file_path) + + if format == "pdf": + file_content = docx_to_pdf_bytes(file_content) + media_type = "application/pdf" + filename = f"{template.name}.pdf" + else: + media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + filename = f"{template.name}.docx" + + return Response( + content=file_content, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/{template_id}") +async def delete_template(template_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Template).where(Template.id == template_id)) + template = result.scalar_one_or_none() + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + delete_file(template.file_path) + await db.delete(template) + return {"detail": "删除成功"} diff --git a/backend/app/main.py b/backend/app/main.py index 7686a38..2ac7adb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import get_settings +from app.api.router import api_router settings = get_settings() @@ -17,6 +18,8 @@ app.add_middleware( allow_headers=["*"], ) +app.include_router(api_router) + @app.get("/health") async def health_check(): diff --git a/backend/app/schemas/ai_model.py b/backend/app/schemas/ai_model.py new file mode 100644 index 0000000..002f011 --- /dev/null +++ b/backend/app/schemas/ai_model.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + + +class AIModelCreate(BaseModel): + name: str = Field(..., max_length=100) + provider: str = Field(..., max_length=50) + endpoint: str = Field(..., max_length=255) + api_key: str + extra_params: dict = Field(default_factory=dict) + is_enabled: bool = True + remark: str | None = None + + +class AIModelUpdate(BaseModel): + name: str | None = Field(None, max_length=100) + provider: str | None = Field(None, max_length=50) + endpoint: str | None = Field(None, max_length=255) + api_key: str | None = None + extra_params: dict | None = None + is_enabled: bool | None = None + remark: str | None = None + + +class AIModelToggle(BaseModel): + is_enabled: bool + + +class AIModelResponse(BaseModel): + id: uuid.UUID + name: str + provider: str + endpoint: str + api_key: str = Field(default="***") + extra_params: dict + is_enabled: bool + remark: str | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/generation_point.py b/backend/app/schemas/generation_point.py new file mode 100644 index 0000000..0f9369d --- /dev/null +++ b/backend/app/schemas/generation_point.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + + +class GenerationPointCreate(BaseModel): + template_id: uuid.UUID + position: dict = Field(..., description="选区位置信息") + prompt: str + model_id: uuid.UUID | None = None + order: int = 0 + + +class GenerationPointUpdate(BaseModel): + position: dict | None = None + prompt: str | None = None + model_id: uuid.UUID | None = None + order: int | None = None + + +class GenerationPointResponse(BaseModel): + id: uuid.UUID + template_id: uuid.UUID + position: dict + prompt: str + model_id: uuid.UUID | None = None + ref_file_path: str | None = None + order: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class BatchOrderUpdate(BaseModel): + points: list[dict] = Field(..., description="[{id: uuid, order: int}, ...]") diff --git a/backend/app/schemas/generation_task.py b/backend/app/schemas/generation_task.py new file mode 100644 index 0000000..7047358 --- /dev/null +++ b/backend/app/schemas/generation_task.py @@ -0,0 +1,30 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +class TaskResponse(BaseModel): + id: uuid.UUID + template_id: uuid.UUID + status: str + result_file_path: str | None = None + error_msg: str | None = None + created_at: datetime + finished_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class GenerateResponse(BaseModel): + task_id: uuid.UUID + status: str + + +class SingleTestRequest(BaseModel): + prompt: str + model_id: uuid.UUID | None = None + ref_file_path: str | None = None + + +class SingleTestResponse(BaseModel): + result: str diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py new file mode 100644 index 0000000..d9accf3 --- /dev/null +++ b/backend/app/schemas/template.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +class TemplateResponse(BaseModel): + id: uuid.UUID + name: str + file_path: str + html_content: str | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TemplateListItem(BaseModel): + id: uuid.UUID + name: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class HTMLContentResponse(BaseModel): + html_content: str + + +class HTMLUpdateRequest(BaseModel): + html_content: str diff --git a/backend/app/services/document_processor.py b/backend/app/services/document_processor.py new file mode 100644 index 0000000..dc34ddf --- /dev/null +++ b/backend/app/services/document_processor.py @@ -0,0 +1,78 @@ +import mammoth +from io import BytesIO +from docx import Document +from docx.shared import Pt +from app.services.file_storage import get_file_content + + +async def docx_to_html(file_content: bytes) -> str: + result = mammoth.convert_to_html(BytesIO(file_content)) + return result.value + + +def html_to_docx_bytes(html_content: str) -> bytes: + doc = Document() + + style = doc.styles["Normal"] + font = style.font + font.name = "Arial" + font.size = Pt(11) + + from html.parser import HTMLParser + + class DocxHTMLParser(HTMLParser): + def __init__(self): + super().__init__() + self.in_p = False + self.current_text = "" + self.paragraphs: list[str] = [] + + def handle_starttag(self, tag, attrs): + if tag in ("p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "li"): + self.in_p = True + self.current_text = "" + + def handle_endtag(self, tag): + if tag in ("p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "li"): + if self.current_text.strip(): + self.paragraphs.append(self.current_text.strip()) + self.current_text = "" + self.in_p = False + + def handle_data(self, data): + self.current_text += data + + parser = DocxHTMLParser() + parser.feed(html_content) + + for text in parser.paragraphs: + doc.add_paragraph(text) + + output = BytesIO() + doc.save(output) + return output.getvalue() + + +def docx_to_pdf_bytes(file_content: bytes) -> bytes: + doc = Document(BytesIO(file_content)) + + from io import BytesIO as Bio + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer + from reportlab.lib.enums import TA_LEFT + + buffer = Bio() + pdf_doc = SimpleDocTemplate(buffer, pagesize=A4) + + styles = getSampleStyleSheet() + story = [] + + for para in doc.paragraphs: + if para.text.strip(): + p = Paragraph(para.text, styles["Normal"]) + story.append(p) + story.append(Spacer(1, 6)) + + pdf_doc.build(story) + return buffer.getvalue()