first commit

This commit is contained in:
2026-03-09 14:46:56 +08:00
commit 62236eb80e
63 changed files with 6143 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API routes

View File

@@ -0,0 +1,41 @@
"""Analysis API: get flow graph and summary for a case."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.database import get_db
from app.models import Case, Transaction
from app.schemas import TransactionResponse, AnalysisSummaryResponse, FlowGraphResponse
from app.services.analyzer import build_flow_graph
router = APIRouter()
@router.get("/{case_id}/transactions", response_model=list[TransactionResponse])
async def list_transactions(case_id: int, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
if not r.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Case not found")
r = await db.execute(
select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time, Transaction.id)
)
txns = r.scalars().all()
return [TransactionResponse.model_validate(t) for t in txns]
@router.get("/{case_id}/analysis")
async def get_analysis(case_id: int, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
if not r.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Case not found")
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id))
txns = r.scalars().all()
items = [TransactionResponse.model_validate(t) for t in txns]
graph, summary = build_flow_graph(items)
return {"summary": summary.model_dump(), "graph": graph.model_dump()}
@router.post("/{case_id}/analysis")
async def run_analysis(case_id: int, db: AsyncSession = Depends(get_db)):
return await get_analysis(case_id, db)

72
backend/app/api/cases.py Normal file
View File

@@ -0,0 +1,72 @@
"""Case CRUD API."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.database import get_db
from app.models import Case
from app.schemas import CaseCreate, CaseUpdate, CaseResponse, CaseListResponse
router = APIRouter()
@router.get("", response_model=CaseListResponse)
async def list_cases(db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).order_by(Case.created_at.desc()))
cases = r.scalars().all()
return CaseListResponse(items=[CaseResponse.model_validate(c) for c in cases])
@router.post("", response_model=CaseResponse)
async def create_case(body: CaseCreate, db: AsyncSession = Depends(get_db)):
case = Case(
case_number=body.case_number,
victim_name=body.victim_name,
description=body.description or "",
)
db.add(case)
await db.commit()
await db.refresh(case)
return CaseResponse.model_validate(case)
@router.get("/{case_id}", response_model=CaseResponse)
async def get_case(case_id: int, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
return CaseResponse.model_validate(case)
@router.put("/{case_id}", response_model=CaseResponse)
async def update_case(case_id: int, body: CaseUpdate, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
if body.case_number is not None:
case.case_number = body.case_number
if body.victim_name is not None:
case.victim_name = body.victim_name
if body.description is not None:
case.description = body.description
if body.total_loss is not None:
case.total_loss = body.total_loss
if body.status is not None:
case.status = body.status
await db.commit()
await db.refresh(case)
return CaseResponse.model_validate(case)
@router.delete("/{case_id}")
async def delete_case(case_id: int, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
await db.delete(case)
await db.commit()
return {"ok": True}

47
backend/app/api/export.py Normal file
View File

@@ -0,0 +1,47 @@
"""Export API: Excel and PDF report download."""
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.database import get_db
from app.models import Case, Transaction
from app.schemas import TransactionResponse
router = APIRouter()
@router.get("/{case_id}/export/excel")
async def export_excel(case_id: int, db: AsyncSession = Depends(get_db)):
from app.services.report import build_excel_report
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time))
txns = [TransactionResponse.model_validate(t) for t in r.scalars().all()]
data = await build_excel_report(case, txns)
return StreamingResponse(
BytesIO(data),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=case_{case_id}_report.xlsx"},
)
@router.get("/{case_id}/export/pdf")
async def export_pdf(case_id: int, db: AsyncSession = Depends(get_db)):
from app.services.report import build_pdf_report
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time))
txns = [TransactionResponse.model_validate(t) for t in r.scalars().all()]
data = await build_pdf_report(case, txns)
return StreamingResponse(
BytesIO(data),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=case_{case_id}_report.pdf"},
)

View File

@@ -0,0 +1,101 @@
"""Screenshot upload and extraction API."""
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.database import get_db
from app.models import Case, Screenshot, Transaction
from app.schemas import ScreenshotResponse, ScreenshotListResponse, TransactionListResponse
from app.services.extractor import extract_and_save
router = APIRouter()
def _allowed(filename: str) -> bool:
ext = (Path(filename).suffix or "").lstrip(".").lower()
return ext in get_settings().allowed_extensions
@router.get("/{case_id}/screenshots", response_model=ScreenshotListResponse)
async def list_screenshots(case_id: int, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Case).where(Case.id == case_id))
if not r.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Case not found")
r = await db.execute(select(Screenshot).where(Screenshot.case_id == case_id).order_by(Screenshot.created_at))
screenshots = r.scalars().all()
return ScreenshotListResponse(items=[ScreenshotResponse.model_validate(s) for s in screenshots])
@router.post("/{case_id}/screenshots", response_model=ScreenshotListResponse)
async def upload_screenshots(
case_id: int,
files: list[UploadFile] = File(...),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(select(Case).where(Case.id == case_id))
case = r.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
settings = get_settings()
upload_dir = settings.upload_dir.resolve()
case_dir = upload_dir / str(case_id)
case_dir.mkdir(parents=True, exist_ok=True)
created: list[Screenshot] = []
for f in files:
if not f.filename or not _allowed(f.filename):
continue
stem = uuid.uuid4().hex[:12]
suffix = Path(f.filename).suffix
path = case_dir / f"{stem}{suffix}"
content = await f.read()
path.write_bytes(content)
rel_path = str(path.relative_to(upload_dir))
screenshot = Screenshot(
case_id=case_id,
filename=f.filename,
file_path=rel_path,
status="pending",
)
db.add(screenshot)
created.append(screenshot)
await db.commit()
for s in created:
await db.refresh(s)
return ScreenshotListResponse(items=[ScreenshotResponse.model_validate(s) for s in created])
@router.post("/{case_id}/screenshots/{screenshot_id}/extract", response_model=TransactionListResponse)
async def extract_transactions(
case_id: int,
screenshot_id: int,
db: AsyncSession = Depends(get_db),
):
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id, Screenshot.case_id == case_id))
screenshot = r.scalar_one_or_none()
if not screenshot:
raise HTTPException(status_code=404, detail="Screenshot not found")
settings = get_settings()
full_path = settings.upload_dir.resolve() / screenshot.file_path
if not full_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
image_bytes = full_path.read_bytes()
try:
transactions = await extract_and_save(case_id, screenshot_id, image_bytes)
except Exception as e:
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id))
sc = r.scalar_one_or_none()
if sc:
sc.status = "failed"
await db.commit()
raise HTTPException(status_code=502, detail=f"Extraction failed: {e!s}")
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id))
sc = r.scalar_one_or_none()
if sc:
sc.status = "extracted"
await db.commit()
return TransactionListResponse(items=transactions)

View File

@@ -0,0 +1,30 @@
"""Runtime settings API for LLM provider and API keys."""
from pydantic import BaseModel
from fastapi import APIRouter
from app.config import public_settings, update_runtime_settings
router = APIRouter()
class SettingsUpdate(BaseModel):
llm_provider: str | None = None
openai_api_key: str | None = None
anthropic_api_key: str | None = None
deepseek_api_key: str | None = None
custom_openai_api_key: str | None = None
custom_openai_base_url: str | None = None
custom_openai_model: str | None = None
@router.get("")
async def get_runtime_settings():
return public_settings()
@router.put("")
async def update_settings(body: SettingsUpdate):
payload = body.model_dump(exclude_unset=True)
update_runtime_settings(payload)
return public_settings()