feat: 阶段二 - 核心业务 API 实现

- Pydantic Schemas: AIModel, Template, GenerationPoint, GenerationTask
- 文档处理服务: docx↔HTML (Mammoth), docx→PDF (reportlab)
- AI 模型管理 API: CRUD + 启用/禁用切换 + API Key 加密
- 模板管理 API: 上传/转换HTML/在线编辑保存/下载(docx+pdf)
- 生成点管理 API: CRUD + 参考文件上传 + 批量排序
- 17 个 API 端点注册,后端启动验证通过
This commit is contained in:
zwt13703 2026-07-06 16:18:23 +08:00
parent b5fa343bf8
commit aa84521449
11 changed files with 584 additions and 0 deletions

View File

@ -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": "排序更新成功"}

99
backend/app/api/models.py Normal file
View File

@ -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

14
backend/app/api/router.py Normal file
View File

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

5
backend/app/api/tasks.py Normal file
View File

@ -0,0 +1,5 @@
from fastapi import APIRouter
router = APIRouter(prefix="/tasks", tags=["生成任务"])
# 任务管理 API 将在阶段三实现

View File

@ -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": "删除成功"}

View File

@ -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():

View File

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

View File

@ -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}, ...]")

View File

@ -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

View File

@ -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

View File

@ -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()