Files
aiform_prod/backend/app/main.py
2026-02-23 11:31:52 +03:00

422 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Ticket Form Intake Platform - FastAPI Backend
"""
from fastapi import FastAPI, Request
import json
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
import time
import uuid
from typing import Any, Dict, Optional, Tuple
from .config import settings, get_cors_origins_live, get_settings
from .services.database import db
from .services.redis_service import redis_service
from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_service
from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, documents_draft_open
from .api import debug_session
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
DEBUG_SESSION_ID = "2a4d38"
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
def _debug_write(
*,
hypothesis_id: str,
run_id: str,
location: str,
message: str,
data: Dict[str, Any],
) -> None:
"""
NDJSON debug log for Cursor Debug Mode.
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
"""
try:
ts = int(time.time() * 1000)
entry = {
"sessionId": DEBUG_SESSION_ID,
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
"timestamp": ts,
"location": location,
"message": message,
"data": data,
"runId": run_id,
"hypothesisId": hypothesis_id,
}
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
# Never break prod request handling due to debug logging
return
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
"""
logs = payload.get("logs") or []
if not isinstance(logs, list):
return (None, None, None)
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "boot":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
build = data.get("build") if isinstance(data.get("build"), str) else None
return (module_url, script_src, build)
return (None, None, None)
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
logs = payload.get("logs") or []
if not isinstance(logs, list):
return {}
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "window_error":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
# Keep only safe fields
return {
"message": data.get("message"),
"filename": data.get("filename"),
"lineno": data.get("lineno"),
"colno": data.get("colno"),
"hasStack": bool(data.get("stack")),
}
return {}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifecycle events: startup and shutdown
"""
# STARTUP
logger.info("🚀 Starting Ticket Form Intake Platform...")
try:
# Подключаем PostgreSQL
await db.connect()
except Exception as e:
logger.warning(f"⚠️ PostgreSQL not available: {e}")
try:
# Подключаем Redis
await redis_service.connect()
# Инициализируем session API с Redis connection
session.init_redis(redis_service.client)
except Exception as e:
logger.warning(f"⚠️ Redis not available: {e}")
try:
# Подключаем RabbitMQ
await rabbitmq_service.connect()
except Exception as e:
logger.warning(f"⚠️ RabbitMQ not available: {e}")
try:
# Подключаем MySQL (для проверки полисов)
await policy_service.connect()
except Exception as e:
logger.warning(f"⚠️ MySQL Policy DB not available: {e}")
try:
# Подключаем MySQL CRM (vtiger)
await crm_mysql_service.connect()
except Exception as e:
logger.warning(f"⚠️ MySQL CRM DB not available: {e}")
try:
# Подключаем S3 (для загрузки файлов)
s3_service.connect()
except Exception as e:
logger.warning(f"⚠️ S3 storage not available: {e}")
logger.info("✅ Ticket Form Intake Platform started successfully!")
yield
# SHUTDOWN
logger.info("🛑 Shutting down Ticket Form Intake Platform...")
await db.disconnect()
await redis_service.disconnect()
await rabbitmq_service.disconnect()
await policy_service.close()
await crm_mysql_service.close()
logger.info("👋 Ticket Form Intake Platform stopped")
# Создаём FastAPI приложение
app = FastAPI(
title="Ticket Form Intake API",
description="API для обработки обращений Ticket Form",
version="1.0.0",
lifespan=lifespan
)
# CORS (список обновляется при изменении .env без перезапуска)
app.add_middleware(
CORSMiddleware,
allow_origins=get_cors_origins_live(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Обновление конфига с .env при каждом запросе, чтобы CORS и прочее подхватывали изменения
@app.middleware("http")
async def refresh_config_on_request(request, call_next):
get_settings()
return await call_next(request)
# Temporary middleware for capturing incoming init_data / startapp / claim_id for debugging.
@app.middleware("http")
async def capture_initdata_middleware(request, call_next):
try:
# Check query string first
qs = str(request.url.query or "")
if qs and ("claim_id" in qs or "startapp" in qs or "start_param" in qs):
logger.info("[CAPTURE Q] %s %s QUERY: %s", request.method, request.url.path, qs)
# Check JSON body for known keys
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.body()
if body:
text = body.decode(errors="ignore")
if any(k in text for k in ("init_data", "startapp", "start_param", "claim_id")):
# Log truncated body (limit 10k chars)
snippet = text if len(text) <= 10000 else (text[:10000] + "...[truncated]")
logger.info("[CAPTURE B] %s %s BODY: %s", request.method, request.url.path, snippet)
except Exception:
logger.exception("❌ Error in capture_initdata_middleware")
return await call_next(request)
# API Routes
app.include_router(sms.router)
app.include_router(claims.router)
app.include_router(policy.router)
app.include_router(upload.router)
app.include_router(draft.router)
app.include_router(events.router)
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
app.include_router(session.router) # 🔑 Session management через Redis
app.include_router(documents.router) # 📄 Documents upload and processing
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
app.include_router(max_auth.router) # 📱 MAX Mini App auth
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
@app.get("/")
async def root():
"""Главная страница API"""
return {
"message": "🚀 Ticket Form Intake API",
"version": "1.0.0",
"status": "running",
"docs": f"{settings.backend_url}/docs"
}
@app.get("/health")
async def health():
"""Health check - проверка всех сервисов"""
health_status = {
"status": "ok",
"message": "API работает!",
"services": {}
}
# Проверка PostgreSQL
try:
pg_healthy = await db.health_check()
health_status["services"]["postgresql"] = {
"status": "✅ healthy" if pg_healthy else "❌ unhealthy",
"connected": pg_healthy
}
except:
health_status["services"]["postgresql"] = {
"status": "❌ unavailable",
"connected": False
}
# Проверка Redis
try:
redis_healthy = await redis_service.health_check()
health_status["services"]["redis"] = {
"status": "✅ healthy" if redis_healthy else "❌ unhealthy",
"connected": redis_healthy
}
except:
health_status["services"]["redis"] = {
"status": "❌ unavailable",
"connected": False
}
# Проверка RabbitMQ
try:
rabbitmq_healthy = await rabbitmq_service.health_check()
health_status["services"]["rabbitmq"] = {
"status": "✅ healthy" if rabbitmq_healthy else "❌ unhealthy",
"connected": rabbitmq_healthy
}
except:
health_status["services"]["rabbitmq"] = {
"status": "❌ unavailable",
"connected": False
}
# Общий статус
all_healthy = all(
service.get("connected", False)
for service in health_status["services"].values()
)
if not all_healthy:
health_status["status"] = "degraded"
health_status["message"] = "⚠️ Некоторые сервисы недоступны"
return health_status
@app.get("/api/v1/test")
async def test():
"""Тестовый endpoint"""
return {
"success": True,
"message": "✅ Backend API работает!",
"services": {
"redis": f"{settings.redis_host}:{settings.redis_port}",
"postgres": f"{settings.postgres_host}:{settings.postgres_port}",
"ocr": settings.ocr_api_url,
"rabbitmq": f"{settings.rabbitmq_host}:{settings.rabbitmq_port}"
}
}
@app.get("/api/v1/utils/client-ip")
async def get_client_ip(request: Request):
"""Возвращает IP-адрес клиента по HTTP-запросу"""
client_host = request.client.host if request.client else None
return {
"ip": client_host
}
@app.post("/api/v1/utils/client-log")
async def client_log(request: Request):
"""
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
Формат: { reason, client: {...}, logs: [...] }
"""
client_host = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
try:
payload = await request.json()
except Exception:
payload = {"error": "invalid_json"}
# Cursor debug-mode evidence (sanitized)
try:
if isinstance(payload, dict):
reason = payload.get("reason")
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
module_url, script_src, build = _extract_client_bundle_info(payload)
last_err = _extract_last_window_error(payload)
first_err_file = None
last_err_file = None
if isinstance(logs, list):
for e in logs:
if isinstance(e, dict) and e.get("event") == "window_error":
d = e.get("data") if isinstance(e.get("data"), dict) else {}
fn = d.get("filename")
if isinstance(fn, str):
if first_err_file is None:
first_err_file = fn
last_err_file = fn
_debug_write(
hypothesis_id="H1",
run_id="pre-fix",
location="backend/app/main.py:client_log",
message="client_log_received",
data={
"ip": client_host,
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
"reason": reason,
"origin": origin,
"pathname": pathname,
"logsCount": len(logs) if isinstance(logs, list) else None,
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
"windowErrorLast": last_err,
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
},
)
except Exception:
pass
# Ограничим размер вывода, но оставим самое важное
try:
s = json.dumps(payload, ensure_ascii=False)[:20000]
except Exception:
s = str(payload)[:20000]
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
return {"success": True}
@app.get("/api/v1/info")
async def info():
"""Информация о платформе"""
return {
"platform": settings.app_name,
"version": "1.0.0",
"tech_stack": {
"backend": "Python FastAPI",
"frontend": "React TypeScript",
"database": "PostgreSQL + MySQL",
"cache": "Redis",
"queue": "RabbitMQ",
"storage": "S3 Timeweb"
},
"features": [
"OCR документов",
"AI автозаполнение",
"Проверка статуса выплат",
"СБП выплаты",
"Интеграция с CRM Vtiger"
]
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8200)