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:
jungwoo choi
2026-02-13 13:57:27 +09:00
parent c37cda5b13
commit b5fa5d96b9
93 changed files with 18735 additions and 22 deletions

View File

View File

@ -0,0 +1,50 @@
"""
Health check router.
GET /api/health
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter
from app.core.database import get_db
from app.core.redis import get_redis
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/health")
async def health_check():
"""Check system health including MongoDB and Redis connectivity."""
services = {}
# Check MongoDB
try:
db = get_db()
await db.command("ping")
services["mongodb"] = "connected"
except Exception as e:
logger.error("MongoDB health check failed: %s", str(e))
services["mongodb"] = "disconnected"
# Check Redis
try:
redis = get_redis()
await redis.ping()
services["redis"] = "connected"
except Exception as e:
logger.error("Redis health check failed: %s", str(e))
services["redis"] = "disconnected"
# Overall status
all_connected = all(v == "connected" for v in services.values())
status = "healthy" if all_connected else "degraded"
return {
"status": status,
"timestamp": datetime.now(timezone.utc).isoformat(),
"services": services,
}

View 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

View File

@ -0,0 +1,81 @@
"""
Reports router.
Handles PDF and JSON report download.
"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from app.core.database import get_db
from app.core.redis import get_redis
from app.services.inspection_service import InspectionService
from app.services.report_service import ReportService
logger = logging.getLogger(__name__)
router = APIRouter()
report_service = ReportService()
def _get_inspection_service() -> InspectionService:
"""Get InspectionService instance."""
db = get_db()
redis = get_redis()
return InspectionService(db=db, redis=redis)
@router.get("/inspections/{inspection_id}/report/pdf")
async def download_pdf(inspection_id: str):
"""Download inspection report as PDF."""
service = _get_inspection_service()
inspection = await service.get_inspection(inspection_id)
if inspection is None:
raise HTTPException(
status_code=404,
detail="검사 결과를 찾을 수 없습니다",
)
try:
pdf_bytes = await report_service.generate_pdf(inspection)
except RuntimeError as e:
raise HTTPException(
status_code=500,
detail=str(e),
)
filename = report_service.generate_filename(inspection.get("url", "unknown"), "pdf")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@router.get("/inspections/{inspection_id}/report/json")
async def download_json(inspection_id: str):
"""Download inspection report as JSON file."""
service = _get_inspection_service()
inspection = await service.get_inspection(inspection_id)
if inspection is None:
raise HTTPException(
status_code=404,
detail="검사 결과를 찾을 수 없습니다",
)
json_bytes = await report_service.generate_json(inspection)
filename = report_service.generate_filename(inspection.get("url", "unknown"), "json")
return Response(
content=json_bytes,
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)