feat: 웹사이트 표준화 검사 도구 구현
- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목) - FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트) - Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트) - Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392) - 전체 테스트 32/32 PASS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
252
backend/app/routers/inspections.py
Normal file
252
backend/app/routers/inspections.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""
|
||||
Inspections router.
|
||||
Handles inspection lifecycle: start, SSE stream, result, issues, history, trend.
|
||||
|
||||
IMPORTANT: Static paths (/batch, /trend) must be registered BEFORE
|
||||
dynamic paths (/{id}) to avoid routing conflicts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import HttpUrl, ValidationError
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.redis import get_redis, get_current_progress
|
||||
from app.models.schemas import StartInspectionRequest, StartInspectionResponse
|
||||
from app.services.inspection_service import InspectionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> InspectionService:
|
||||
"""Get InspectionService instance."""
|
||||
db = get_db()
|
||||
redis = get_redis()
|
||||
return InspectionService(db=db, redis=redis)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# POST /api/inspections -- Start inspection
|
||||
# ============================================================
|
||||
|
||||
@router.post("/inspections", status_code=202)
|
||||
async def start_inspection(request: StartInspectionRequest):
|
||||
"""
|
||||
Start a new web inspection.
|
||||
Returns 202 Accepted with inspection_id immediately.
|
||||
Inspection runs asynchronously in the background.
|
||||
"""
|
||||
url = str(request.url)
|
||||
|
||||
# Validate URL scheme
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="유효한 URL을 입력해주세요 (http:// 또는 https://로 시작해야 합니다)",
|
||||
)
|
||||
|
||||
service = _get_service()
|
||||
|
||||
try:
|
||||
inspection_id = await service.start_inspection(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"해당 URL에 접근할 수 없습니다 (HTTP {e.response.status_code})",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다 (응답 시간 초과)",
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start inspection: %s", str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다",
|
||||
)
|
||||
|
||||
return StartInspectionResponse(
|
||||
inspection_id=inspection_id,
|
||||
status="running",
|
||||
url=url,
|
||||
stream_url=f"/api/inspections/{inspection_id}/stream",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections -- List inspections (history)
|
||||
# IMPORTANT: This MUST be before /{inspection_id} routes
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections")
|
||||
async def list_inspections(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
url: Optional[str] = Query(default=None),
|
||||
sort: str = Query(default="-created_at"),
|
||||
):
|
||||
"""Get paginated inspection history."""
|
||||
service = _get_service()
|
||||
result = await service.get_inspection_list(
|
||||
page=page,
|
||||
limit=limit,
|
||||
url_filter=url,
|
||||
sort=sort,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/trend -- Trend data
|
||||
# IMPORTANT: Must be before /{inspection_id} to avoid conflict
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/trend")
|
||||
async def get_trend(
|
||||
url: str = Query(..., description="Target URL for trend data"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
):
|
||||
"""Get trend data (score history) for a specific URL."""
|
||||
service = _get_service()
|
||||
result = await service.get_trend(url=url, limit=limit)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id}/stream -- SSE stream
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}/stream")
|
||||
async def stream_progress(inspection_id: str):
|
||||
"""Stream inspection progress via Server-Sent Events."""
|
||||
|
||||
async def event_generator():
|
||||
redis = get_redis()
|
||||
pubsub = redis.pubsub()
|
||||
channel = f"inspection:{inspection_id}:events"
|
||||
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
try:
|
||||
# Send current state immediately (client may connect mid-progress)
|
||||
current = await get_current_progress(inspection_id)
|
||||
if current:
|
||||
# Build initial progress event
|
||||
categories = {}
|
||||
for cat in ["html_css", "accessibility", "seo", "performance_security"]:
|
||||
cat_progress = int(current.get(f"{cat}_progress", 0))
|
||||
cat_step = current.get(f"{cat}_step", "")
|
||||
cat_status = current.get(f"{cat}_status", "pending")
|
||||
categories[cat] = {
|
||||
"status": cat_status,
|
||||
"progress": cat_progress,
|
||||
"current_step": cat_step,
|
||||
}
|
||||
|
||||
total = sum(c["progress"] for c in categories.values())
|
||||
overall = round(total / 4)
|
||||
|
||||
yield {
|
||||
"event": "progress",
|
||||
"data": json.dumps({
|
||||
"inspection_id": inspection_id,
|
||||
"status": "running",
|
||||
"overall_progress": overall,
|
||||
"categories": categories,
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# Listen for Pub/Sub messages
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
event_data = json.loads(message["data"])
|
||||
event_type = event_data.pop("event_type", "progress")
|
||||
|
||||
yield {
|
||||
"event": event_type,
|
||||
"data": json.dumps(event_data, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# End stream on complete or error
|
||||
if event_type in ("complete", "error"):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SSE stream error for %s: %s", inspection_id, str(e))
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": json.dumps({
|
||||
"inspection_id": inspection_id,
|
||||
"status": "error",
|
||||
"message": "스트리밍 중 오류가 발생했습니다",
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose()
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id} -- Get result
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}")
|
||||
async def get_inspection(inspection_id: str):
|
||||
"""Get inspection result by ID."""
|
||||
service = _get_service()
|
||||
result = await service.get_inspection(inspection_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
# Remove MongoDB _id field if present
|
||||
result.pop("_id", None)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id}/issues -- Get issues
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}/issues")
|
||||
async def get_issues(
|
||||
inspection_id: str,
|
||||
category: Optional[str] = Query(default=None),
|
||||
severity: Optional[str] = Query(default=None),
|
||||
):
|
||||
"""Get filtered issue list for an inspection."""
|
||||
service = _get_service()
|
||||
result = await service.get_issues(
|
||||
inspection_id=inspection_id,
|
||||
category=category,
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user