mirror of
https://github.com/nethunterzist/trendyol-analiz
synced 2026-07-03 10:17:03 +00:00
feat: PostgreSQL kuyruk sistemi (SSE yerine queue+poll)
Ne yaptik:
- ReportQueue modeli + Alembic migration (report_queue tablosu)
- QueueWorker: SELECT FOR UPDATE SKIP LOCKED ile tek worker polling
- 3 yeni endpoint: POST /api/queue/submit, GET /api/queue/{id}/status, GET /api/queue/active
- Startup hook ile worker otomatik basliyor, shutdown'da duruyor
- Stuck task recovery: 15 dk'dan eski PROCESSING tasklar PENDING'e donuyor
Neden yaptik:
- Esanli SSE rapor isteklerinde IP ban riski ve veri kaybi vardi
- Tek worker ile sirayla isleniyor, rate limit korunuyor
- Sifir yeni dependency: sadece PostgreSQL + mevcut scraper'lar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
171
backend/main.py
171
backend/main.py
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
FastAPI Backend for Trendyol Admin Panel
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
|
||||
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Request, Security
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import APIKeyHeader
|
||||
import asyncio
|
||||
import json as json_module
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -22,7 +23,7 @@ from collections import OrderedDict
|
||||
from threading import Lock
|
||||
import os
|
||||
|
||||
from database import SessionLocal, Category, Snapshot, Report, EnrichmentError, init_db
|
||||
from database import SessionLocal, Category, Snapshot, Report, EnrichmentError, ReportQueue, init_db
|
||||
from google_trends_helper import estimate_traffic_sources, fetch_google_trends
|
||||
from logging_config import setup_logging, get_logger, set_correlation_id, set_report_id, log_timing
|
||||
|
||||
@@ -40,6 +41,32 @@ log_keywords = get_logger("keywords")
|
||||
|
||||
init_db()
|
||||
|
||||
# ============================================================================
|
||||
# API KEY AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
API_KEY = os.getenv("API_KEY")
|
||||
if not API_KEY:
|
||||
import warnings
|
||||
warnings.warn("API_KEY env var not set! API authentication disabled in development.")
|
||||
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
# Paths that do not require API key authentication
|
||||
PUBLIC_PATHS = {"/health"}
|
||||
|
||||
async def verify_api_key(request: Request, api_key: Optional[str] = Security(API_KEY_HEADER)):
|
||||
"""
|
||||
Global dependency that validates X-API-Key header.
|
||||
Skips authentication for public paths (health check, docs).
|
||||
"""
|
||||
if request.url.path in PUBLIC_PATHS:
|
||||
return
|
||||
if not api_key or api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or missing API key"
|
||||
)
|
||||
|
||||
# GS1 Barcode Prefix to Country Mapping (EAN-13 / EAN-8)
|
||||
# Source: https://www.gs1.org/standards/id-keys/company-prefix
|
||||
BARCODE_PREFIX_TO_COUNTRY = {
|
||||
@@ -259,7 +286,11 @@ def get_country_from_barcode(barcode: str) -> str:
|
||||
prefix = barcode[:3]
|
||||
return BARCODE_PREFIX_TO_COUNTRY.get(prefix, "Bilinmeyen")
|
||||
|
||||
app = FastAPI(title="Trendyol Admin API", version="1.0.0")
|
||||
app = FastAPI(
|
||||
title="Trendyol Admin API",
|
||||
version="1.0.0",
|
||||
dependencies=[Depends(verify_api_key)]
|
||||
)
|
||||
|
||||
# Base directory for resolving relative paths
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -273,45 +304,18 @@ CATEGORY_TREE_PATH = os.getenv("CATEGORY_TREE_PATH", os.path.join(BASE_DIR, ".."
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", os.path.join(BASE_DIR, "trendyol.db"))
|
||||
|
||||
# CORS for React admin panel
|
||||
# Security: Specify exact origins instead of wildcard
|
||||
# Supports: Local development, Docker Compose, and production deployment
|
||||
allowed_origins = [
|
||||
# Local development (Vite dev server)
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175",
|
||||
"http://localhost:5176",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:5175",
|
||||
"http://127.0.0.1:5176",
|
||||
"http://127.0.0.1:3000",
|
||||
# Docker Compose internal networking
|
||||
"http://frontend",
|
||||
"http://frontend:80",
|
||||
# Docker host access (mapped ports)
|
||||
"http://localhost:80",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:80",
|
||||
"http://127.0.0.1:8080",
|
||||
# Production server (Coolify)
|
||||
"http://194.187.253.230:3010",
|
||||
"http://194.187.253.230",
|
||||
# Coolify Traefik proxy (sslip.io)
|
||||
"http://trendyol.194.187.253.230.sslip.io",
|
||||
"https://trendyol.194.187.253.230.sslip.io",
|
||||
"http://trendyol-api.194.187.253.230.sslip.io",
|
||||
"https://trendyol-api.194.187.253.230.sslip.io",
|
||||
]
|
||||
|
||||
# Add production domain from environment variable
|
||||
frontend_url = os.getenv("FRONTEND_URL")
|
||||
if frontend_url:
|
||||
allowed_origins.append(frontend_url)
|
||||
# Also add https variant if http is provided
|
||||
if frontend_url.startswith("http://"):
|
||||
allowed_origins.append(frontend_url.replace("http://", "https://"))
|
||||
# Security: Environment-based origin control
|
||||
allowed_origins = []
|
||||
if os.getenv("ENV", "development") == "production":
|
||||
allowed_origins = [
|
||||
"https://trendyol.194.187.253.230.sslip.io",
|
||||
]
|
||||
else:
|
||||
allowed_origins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -1603,7 +1607,8 @@ async def create_report(
|
||||
# This is a leaf — return itself
|
||||
cat = tree_by_id.get(parent_id)
|
||||
if cat:
|
||||
return [(None, cat["name"], cat["id"])]
|
||||
path_model = (cat.get("url") or "").lstrip("/") or None
|
||||
return [(path_model, cat["name"], cat["id"])]
|
||||
return []
|
||||
leaves = []
|
||||
for child in children:
|
||||
@@ -1883,6 +1888,68 @@ async def create_report(
|
||||
return StreamingResponse(progress_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# QUEUE ENDPOINTS (new — replaces SSE for SellerX Java backend)
|
||||
# ============================================================================
|
||||
|
||||
class QueueSubmitRequest(BaseModel):
|
||||
name: str
|
||||
category_id: int
|
||||
|
||||
@app.post("/api/queue/submit", status_code=202)
|
||||
def submit_to_queue(req: QueueSubmitRequest, db: Session = Depends(get_db)):
|
||||
"""Submit a report generation task to the queue. Returns 202 Accepted."""
|
||||
# Validate category exists
|
||||
tree = _load_category_tree()
|
||||
tree_by_id = {c["id"]: c for c in tree}
|
||||
if req.category_id not in tree_by_id:
|
||||
raise HTTPException(status_code=404, detail=f"Category {req.category_id} not found")
|
||||
|
||||
queue_item = ReportQueue(
|
||||
report_name=req.name,
|
||||
category_id=req.category_id,
|
||||
status="PENDING",
|
||||
progress=0,
|
||||
)
|
||||
db.add(queue_item)
|
||||
db.commit()
|
||||
db.refresh(queue_item)
|
||||
|
||||
# Calculate position in queue
|
||||
position = db.query(func.count(ReportQueue.id)).filter(
|
||||
ReportQueue.status == "PENDING",
|
||||
ReportQueue.created_at <= queue_item.created_at,
|
||||
).scalar() or 1
|
||||
|
||||
log_api.info(f"Queue submit: id={queue_item.id}, name={req.name}, category={req.category_id}, position={position}")
|
||||
return {"queue_id": queue_item.id, "status": "PENDING", "position": position}
|
||||
|
||||
|
||||
@app.get("/api/queue/{queue_id}/status")
|
||||
def get_queue_status(queue_id: int, db: Session = Depends(get_db)):
|
||||
"""Get the status of a queued report task."""
|
||||
item = db.query(ReportQueue).filter(ReportQueue.id == queue_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Queue item not found")
|
||||
|
||||
return {
|
||||
"queue_id": item.id,
|
||||
"status": item.status,
|
||||
"progress": item.progress,
|
||||
"message": item.message or "",
|
||||
"result_report_id": item.result_report_id,
|
||||
"error": item.error,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/queue/active")
|
||||
def get_queue_info(db: Session = Depends(get_db)):
|
||||
"""Get queue overview: pending and processing counts."""
|
||||
pending = db.query(func.count(ReportQueue.id)).filter(ReportQueue.status == "PENDING").scalar() or 0
|
||||
processing = db.query(func.count(ReportQueue.id)).filter(ReportQueue.status == "PROCESSING").scalar() or 0
|
||||
return {"pending_count": pending, "processing_count": processing}
|
||||
|
||||
|
||||
# Update report
|
||||
|
||||
@app.get("/api/reports/{report_id}", response_model=ReportResponse)
|
||||
@@ -4040,6 +4107,24 @@ async def _start_resource_logger():
|
||||
_resource_logger.info("Periodic resource logger started (60s interval)")
|
||||
|
||||
|
||||
# ── Queue Worker Lifecycle ──────────────────────────────────────────────
|
||||
_queue_worker = None
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_queue_worker():
|
||||
global _queue_worker
|
||||
from queue_worker import QueueWorker
|
||||
_queue_worker = QueueWorker(poll_interval=2.0)
|
||||
_queue_worker.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _stop_queue_worker():
|
||||
global _queue_worker
|
||||
if _queue_worker:
|
||||
_queue_worker.stop()
|
||||
_queue_worker = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
Reference in New Issue
Block a user