init
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
195
backend/app/api/export.py
Normal file
195
backend/app/api/export.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import json
|
||||
import zipfile
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story
|
||||
from app.models.person import Person
|
||||
from app.config import settings
|
||||
from app.services.docx_generator import generate_person_doc
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["导出"])
|
||||
|
||||
UNKNOWN_PERSON_ID = "unknown"
|
||||
UNKNOWN_PERSON_NAME = "暂无"
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def export_list(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取导出列表(所有已匹配的讲述者,含'暂无')"""
|
||||
# 获取所有已匹配的故事按 person_id 分组
|
||||
result = await db.execute(
|
||||
select(Story.person_id)
|
||||
.where(Story.match_status == "matched")
|
||||
.distinct()
|
||||
)
|
||||
person_ids = [row[0] for row in result.all()]
|
||||
|
||||
items = []
|
||||
for pid in person_ids:
|
||||
if pid == UNKNOWN_PERSON_ID:
|
||||
story_result = await db.execute(
|
||||
select(Story).where(
|
||||
Story.person_id == UNKNOWN_PERSON_ID,
|
||||
Story.match_status == "matched",
|
||||
)
|
||||
)
|
||||
stories = story_result.scalars().all()
|
||||
items.append({
|
||||
"person_id": UNKNOWN_PERSON_ID,
|
||||
"person_name": UNKNOWN_PERSON_NAME,
|
||||
"story_count": len(stories),
|
||||
"has_photo": False,
|
||||
})
|
||||
else:
|
||||
person = await db.get(Person, pid)
|
||||
if not person:
|
||||
continue
|
||||
story_result = await db.execute(
|
||||
select(Story).where(
|
||||
Story.person_id == person.id,
|
||||
Story.match_status == "matched",
|
||||
)
|
||||
)
|
||||
stories = story_result.scalars().all()
|
||||
items.append({
|
||||
"person_id": person.id,
|
||||
"person_name": person.name,
|
||||
"story_count": len(stories),
|
||||
"has_photo": bool(person.photo_path),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/preview/{person_id}")
|
||||
async def preview_export(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""预览某讲述者的文档内容"""
|
||||
if person_id == UNKNOWN_PERSON_ID:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
person_info = ""
|
||||
has_photo = False
|
||||
else:
|
||||
person = await db.get(Person, person_id)
|
||||
if not person:
|
||||
raise HTTPException(404, "讲述者不存在")
|
||||
person_name = person.name
|
||||
person_info = person.info
|
||||
has_photo = bool(person.photo_path)
|
||||
|
||||
story_result = await db.execute(
|
||||
select(Story).where(
|
||||
Story.person_id == person_id,
|
||||
Story.match_status == "matched",
|
||||
)
|
||||
)
|
||||
stories = story_result.scalars().all()
|
||||
|
||||
return {
|
||||
"person_name": person_name,
|
||||
"person_info": person_info,
|
||||
"has_photo": has_photo,
|
||||
"stories": [{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content, "raw_material": s.raw_material or ""} for s in stories],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/download/{person_id}")
|
||||
async def download_one(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""下载单个讲述者的 DOCX"""
|
||||
if person_id == UNKNOWN_PERSON_ID:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
person_info = ""
|
||||
photo_path = ""
|
||||
else:
|
||||
person = await db.get(Person, person_id)
|
||||
if not person:
|
||||
raise HTTPException(404, "讲述者不存在")
|
||||
person_name = person.name
|
||||
person_info = person.info
|
||||
photo_path = person.photo_path or ""
|
||||
|
||||
story_result = await db.execute(
|
||||
select(Story).where(
|
||||
Story.person_id == person_id,
|
||||
Story.match_status == "matched",
|
||||
)
|
||||
)
|
||||
stories = story_result.scalars().all()
|
||||
if not stories:
|
||||
raise HTTPException(400, "该讲述者没有已匹配的故事")
|
||||
|
||||
output_path = os.path.join(settings.EXPORT_DIR, f"{person_name}_故事集.docx")
|
||||
generate_person_doc(
|
||||
person_name=person_name,
|
||||
person_info=person_info,
|
||||
photo_path=photo_path,
|
||||
stories=[{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content} for s in stories],
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
output_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=f"{person_name}_故事集.docx",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download-all")
|
||||
async def download_all(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""批量下载所有已匹配讲述者的文档(ZIP)"""
|
||||
# 获取所有已匹配的 person_id
|
||||
result = await db.execute(
|
||||
select(Story.person_id)
|
||||
.where(Story.match_status == "matched")
|
||||
.distinct()
|
||||
)
|
||||
person_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not person_ids:
|
||||
raise HTTPException(400, "没有可导出的文档")
|
||||
|
||||
# 生成 ZIP
|
||||
zip_path = os.path.join(settings.EXPORT_DIR, "故事集_全部.zip")
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for pid in person_ids:
|
||||
if pid == UNKNOWN_PERSON_ID:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
person_info = ""
|
||||
photo_path = ""
|
||||
else:
|
||||
person = await db.get(Person, pid)
|
||||
if not person:
|
||||
continue
|
||||
person_name = person.name
|
||||
person_info = person.info
|
||||
photo_path = person.photo_path or ""
|
||||
|
||||
story_result = await db.execute(
|
||||
select(Story).where(
|
||||
Story.person_id == pid,
|
||||
Story.match_status == "matched",
|
||||
)
|
||||
)
|
||||
stories = story_result.scalars().all()
|
||||
if not stories:
|
||||
continue
|
||||
|
||||
output_path = os.path.join(settings.EXPORT_DIR, f"{person_name}_故事集.docx")
|
||||
generate_person_doc(
|
||||
person_name=person_name,
|
||||
person_info=person_info,
|
||||
photo_path=photo_path,
|
||||
stories=[{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content} for s in stories],
|
||||
output_path=output_path,
|
||||
)
|
||||
zf.write(output_path, f"{person_name}_故事集.docx")
|
||||
|
||||
return FileResponse(
|
||||
zip_path,
|
||||
media_type="application/zip",
|
||||
filename="故事集_全部.zip",
|
||||
)
|
||||
112
backend/app/api/extraction.py
Normal file
112
backend/app/api/extraction.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.transcript import Transcript
|
||||
from app.state import task_manager
|
||||
from app.services.extractor import run_extraction
|
||||
|
||||
router = APIRouter(prefix="/extraction", tags=["故事提取"])
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_extraction(
|
||||
task_id: Optional[str] = Query(None),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
"""启动提取任务,支持多用户并发"""
|
||||
# 如果传了 task_id,复用已有任务
|
||||
if task_id:
|
||||
state = task_manager.get_task(task_id)
|
||||
if state and state.is_running:
|
||||
return {"task_id": task_id, "message": "任务正在进行中"}
|
||||
else:
|
||||
state = None
|
||||
|
||||
# 检查是否有待处理的文字稿
|
||||
result = await db.execute(select(Transcript).where(Transcript.status == "pending"))
|
||||
pending = result.scalars().all()
|
||||
|
||||
if not pending:
|
||||
# 没有 pending 的,把所有非 pending 的文件重置为 pending(包括 completed/processing/error)
|
||||
result2 = await db.execute(
|
||||
select(Transcript).where(Transcript.status != "pending")
|
||||
)
|
||||
others = result2.scalars().all()
|
||||
if others:
|
||||
for t in others:
|
||||
t.status = "pending"
|
||||
t.error_message = ""
|
||||
await db.commit()
|
||||
pending = others
|
||||
else:
|
||||
raise HTTPException(400, "没有可提取的文字稿,请先上传文件")
|
||||
|
||||
total = len(pending)
|
||||
|
||||
# 创建新任务
|
||||
state = task_manager.create_task()
|
||||
state.reset()
|
||||
state.is_running = True
|
||||
|
||||
asyncio.create_task(run_extraction(state))
|
||||
|
||||
return {"task_id": state.task_id, "total_files": total}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(task_id: Optional[str] = Query(None)):
|
||||
"""获取提取进度"""
|
||||
if task_id:
|
||||
state = task_manager.get_task(task_id)
|
||||
if state:
|
||||
return {**state.progress, "task_id": state.task_id}
|
||||
return {"status": "idle", "percent": 0}
|
||||
|
||||
|
||||
@router.get("/status/stream")
|
||||
async def stream_status(task_id: Optional[str] = Query(None)):
|
||||
"""SSE 实时进度推送(支持多用户)"""
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id 参数")
|
||||
|
||||
state = task_manager.get_task(task_id)
|
||||
if not state:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
async def event_generator():
|
||||
# 立即推送当前状态
|
||||
yield f"event: progress\ndata: {json.dumps({**state.progress, 'task_id': state.task_id}, ensure_ascii=False)}\n\n"
|
||||
if state.progress.get("status") in ("completed", "error", "stopped"):
|
||||
return
|
||||
while True:
|
||||
data = await state.get_update()
|
||||
yield f"event: progress\ndata: {json.dumps({**data, 'task_id': state.task_id}, ensure_ascii=False)}\n\n"
|
||||
if data.get("status") in ("completed", "error", "stopped"):
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_extraction(task_id: Optional[str] = Query(None)):
|
||||
"""停止提取任务"""
|
||||
if task_id:
|
||||
state = task_manager.get_task(task_id)
|
||||
else:
|
||||
state = None
|
||||
|
||||
if not state or not state.is_running:
|
||||
raise HTTPException(400, "没有正在进行的提取任务")
|
||||
|
||||
state.cancel_event.set()
|
||||
return {"message": "正在停止...", "task_id": state.task_id}
|
||||
136
backend/app/api/files.py
Normal file
136
backend/app/api/files.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.transcript import Transcript
|
||||
from app.models.person import Person
|
||||
from app.schemas.schemas import TranscriptOut, PersonOut
|
||||
from app.services.file_service import file_service
|
||||
from app.services.docx_parser import parse_transcript, parse_person_info
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["文件管理"])
|
||||
|
||||
|
||||
# ===== 文字稿 =====
|
||||
|
||||
@router.post("/transcripts", response_model=list[TranscriptOut])
|
||||
async def upload_transcripts(files: list[UploadFile], db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""批量上传文字稿"""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.endswith(".docx"):
|
||||
raise HTTPException(400, f"仅支持 .docx 格式: {file.filename}")
|
||||
|
||||
saved = await file_service.save_transcript(file)
|
||||
|
||||
# 解析行数
|
||||
paragraphs = parse_transcript(saved["file_path"])
|
||||
line_count = len(paragraphs)
|
||||
|
||||
record = Transcript(
|
||||
id=saved["id"],
|
||||
filename=saved["filename"],
|
||||
file_path=saved["file_path"],
|
||||
line_count=line_count,
|
||||
file_size=saved["file_size"],
|
||||
status="pending",
|
||||
)
|
||||
db.add(record)
|
||||
results.append(record)
|
||||
|
||||
await db.commit()
|
||||
for r in results:
|
||||
await db.refresh(r)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/transcripts", response_model=list[TranscriptOut])
|
||||
async def list_transcripts(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取文字稿列表"""
|
||||
result = await db.execute(select(Transcript).order_by(Transcript.uploaded_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.delete("/transcripts/{transcript_id}")
|
||||
async def delete_transcript(transcript_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""删除文字稿"""
|
||||
record = await db.get(Transcript, transcript_id)
|
||||
if not record:
|
||||
raise HTTPException(404, "文字稿不存在")
|
||||
file_service.delete_file(record.file_path)
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ===== 讲述者信息 =====
|
||||
|
||||
@router.post("/persons", response_model=list[PersonOut])
|
||||
async def upload_persons(files: list[UploadFile], db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""批量上传讲述者信息"""
|
||||
results = []
|
||||
for file in files:
|
||||
if not file.filename.endswith(".docx"):
|
||||
raise HTTPException(400, f"仅支持 .docx 格式: {file.filename}")
|
||||
|
||||
saved = await file_service.save_person(file)
|
||||
|
||||
# 解析个人信息
|
||||
info = parse_person_info(saved["file_path"])
|
||||
|
||||
record = Person(
|
||||
id=saved["id"],
|
||||
name=info["name"],
|
||||
filename=saved["filename"],
|
||||
file_path=saved["file_path"],
|
||||
photo_path=info["photos"][0] if info["photos"] else "",
|
||||
info=info["info_text"],
|
||||
)
|
||||
db.add(record)
|
||||
results.append(record)
|
||||
|
||||
await db.commit()
|
||||
for r in results:
|
||||
await db.refresh(r)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/persons", response_model=list[PersonOut])
|
||||
async def list_persons(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取讲述者列表"""
|
||||
result = await db.execute(select(Person).order_by(Person.uploaded_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.delete("/persons/{person_id}")
|
||||
async def delete_person(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""删除讲述者信息"""
|
||||
record = await db.get(Person, person_id)
|
||||
if not record:
|
||||
raise HTTPException(404, "讲述者不存在")
|
||||
file_service.delete_file(record.file_path)
|
||||
if record.photo_path:
|
||||
file_service.delete_file(record.photo_path)
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/persons/{person_id}/preview")
|
||||
async def preview_person(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""预览讲述者信息"""
|
||||
record = await db.get(Person, person_id)
|
||||
if not record:
|
||||
raise HTTPException(404, "讲述者不存在")
|
||||
return {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"nickname": record.nickname,
|
||||
"info": record.info,
|
||||
"photo_path": record.photo_path,
|
||||
}
|
||||
76
backend/app/api/matching.py
Normal file
76
backend/app/api/matching.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story
|
||||
from app.models.person import Person
|
||||
from app.schemas.schemas import MatchCreate, MatchOut
|
||||
|
||||
router = APIRouter(prefix="/matches", tags=["匹配管理"])
|
||||
|
||||
# 特殊讲述者 ID:暂无
|
||||
UNKNOWN_PERSON_ID = "unknown"
|
||||
UNKNOWN_PERSON_NAME = "暂无"
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_match(data: MatchCreate, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""创建匹配关系(支持 person_id='unknown' 表示暂无讲述者)"""
|
||||
story = await db.get(Story, data.story_id)
|
||||
if not story or story.match_status == "deleted":
|
||||
raise HTTPException(404, "故事不存在")
|
||||
|
||||
if data.person_id == UNKNOWN_PERSON_ID:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
else:
|
||||
person = await db.get(Person, data.person_id)
|
||||
if not person:
|
||||
raise HTTPException(404, "讲述者不存在")
|
||||
person_name = person.name
|
||||
|
||||
story.person_id = data.person_id
|
||||
story.match_status = "matched"
|
||||
await db.commit()
|
||||
await db.refresh(story)
|
||||
|
||||
return {"message": "匹配成功", "story_id": story.id, "person_id": data.person_id, "person_name": person_name}
|
||||
|
||||
|
||||
@router.delete("/{story_id}")
|
||||
async def remove_match(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""取消匹配"""
|
||||
story = await db.get(Story, story_id)
|
||||
if not story:
|
||||
raise HTTPException(404, "故事不存在")
|
||||
|
||||
story.person_id = None
|
||||
story.match_status = "pending"
|
||||
await db.commit()
|
||||
return {"message": "已取消匹配"}
|
||||
|
||||
|
||||
@router.get("", response_model=list[MatchOut])
|
||||
async def list_matches(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取所有匹配关系"""
|
||||
result = await db.execute(
|
||||
select(Story, Person)
|
||||
.join(Person, Story.person_id == Person.id, isouter=True)
|
||||
.where(Story.match_status == "matched")
|
||||
)
|
||||
matches = []
|
||||
for story, person in result.all():
|
||||
if story.person_id == UNKNOWN_PERSON_ID:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
elif person:
|
||||
person_name = person.name
|
||||
else:
|
||||
person_name = UNKNOWN_PERSON_NAME
|
||||
matches.append({
|
||||
"story_id": story.id,
|
||||
"person_id": story.person_id,
|
||||
"person_name": person_name,
|
||||
"story_title": story.title,
|
||||
})
|
||||
return matches
|
||||
70
backend/app/api/settings.py
Normal file
70
backend/app/api/settings.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.config import Config
|
||||
from app.schemas.schemas import SettingsOut, SettingsUpdate
|
||||
from app.services.llm_client import llm_client
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
|
||||
async def _get_config_value(db: AsyncSession, key: str, default: str = "") -> str:
|
||||
"""获取配置值"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
config = result.scalar_one_or_none()
|
||||
return config.value if config else default
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings(db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取系统配置"""
|
||||
return SettingsOut(
|
||||
llm_provider=await _get_config_value(db, "llm_provider", settings.LLM_PROVIDER),
|
||||
llm_model=await _get_config_value(db, "llm_model", settings.LLM_MODEL),
|
||||
llm_base_url=await _get_config_value(db, "llm_base_url", settings.LLM_BASE_URL),
|
||||
llm_api_key=await _get_config_value(db, "llm_api_key", "****"),
|
||||
segment_max_lines=int(await _get_config_value(db, "segment_max_lines", str(settings.SEGMENT_MAX_LINES))),
|
||||
story_min_lines=int(await _get_config_value(db, "story_min_lines", str(settings.STORY_MIN_LINES))),
|
||||
confidence_threshold=float(await _get_config_value(db, "confidence_threshold", str(settings.CONFIDENCE_THRESHOLD))),
|
||||
temperature=float(await _get_config_value(db, "temperature", str(settings.TEMPERATURE))),
|
||||
)
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(data: SettingsUpdate, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""更新系统配置"""
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
for key, value in updates.items():
|
||||
# API Key 脱敏处理
|
||||
if key == "llm_api_key" and value == "****":
|
||||
continue
|
||||
|
||||
existing = await db.execute(select(Config).where(Config.key == key))
|
||||
config = existing.scalar_one_or_none()
|
||||
if config:
|
||||
config.value = str(value)
|
||||
else:
|
||||
db.add(Config(key=key, value=str(value)))
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 更新 LLM 客户端配置
|
||||
if "llm_base_url" in updates:
|
||||
llm_client.update_config(base_url=updates["llm_base_url"])
|
||||
if "llm_api_key" in updates:
|
||||
llm_client.update_config(api_key=updates["llm_api_key"])
|
||||
if "llm_model" in updates:
|
||||
llm_client.update_config(model=updates["llm_model"])
|
||||
|
||||
return {"message": "配置已保存"}
|
||||
|
||||
|
||||
@router.post("/test-llm")
|
||||
async def test_llm():
|
||||
"""测试 LLM 连接"""
|
||||
result = await llm_client.test_connection()
|
||||
return result
|
||||
64
backend/app/api/stories.py
Normal file
64
backend/app/api/stories.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story
|
||||
from app.schemas.schemas import StoryOut, StoryEdit
|
||||
|
||||
router = APIRouter(prefix="/stories", tags=["故事管理"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[StoryOut])
|
||||
async def list_stories(db: Annotated[AsyncSession, Depends(get_db)], status: str = "all"):
|
||||
"""获取故事列表,支持筛选:all/matched/pending"""
|
||||
query = select(Story).where(Story.match_status != "deleted")
|
||||
if status == "matched":
|
||||
query = query.where(Story.match_status == "matched")
|
||||
elif status == "pending":
|
||||
query = query.where(Story.match_status == "pending")
|
||||
query = query.order_by(Story.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{story_id}", response_model=StoryOut)
|
||||
async def get_story(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""获取故事详情"""
|
||||
story = await db.get(Story, story_id)
|
||||
if not story or story.match_status == "deleted":
|
||||
raise HTTPException(404, "故事不存在")
|
||||
return story
|
||||
|
||||
|
||||
@router.put("/{story_id}", response_model=StoryOut)
|
||||
async def edit_story(story_id: str, data: StoryEdit, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""编辑故事"""
|
||||
story = await db.get(Story, story_id)
|
||||
if not story or story.match_status == "deleted":
|
||||
raise HTTPException(404, "故事不存在")
|
||||
|
||||
if data.title is not None:
|
||||
story.title = data.title
|
||||
if data.summary is not None:
|
||||
story.summary = data.summary
|
||||
if data.content is not None:
|
||||
story.content = data.content
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(story)
|
||||
return story
|
||||
|
||||
|
||||
@router.delete("/{story_id}")
|
||||
async def delete_story(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
"""删除故事(标记为 deleted)"""
|
||||
story = await db.get(Story, story_id)
|
||||
if not story or story.match_status == "deleted":
|
||||
raise HTTPException(404, "故事不存在")
|
||||
|
||||
story.match_status = "deleted"
|
||||
story.person_id = None
|
||||
await db.commit()
|
||||
return {"message": "已删除"}
|
||||
Reference in New Issue
Block a user