461 lines
17 KiB
Python
461 lines
17 KiB
Python
"""
|
||
Ticket Form Intake Platform - FastAPI Backend
|
||
"""
|
||
from fastapi import FastAPI, Request
|
||
import json
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from contextlib import asynccontextmanager
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
import uuid
|
||
from typing import Any, Dict, Optional, Tuple
|
||
|
||
import redis.asyncio as redis
|
||
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, auth_universal, documents_draft_open, profile, support
|
||
from .api import debug_session
|
||
|
||
# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки)
|
||
import sys
|
||
_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO)
|
||
logging.basicConfig(
|
||
level=_level,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
stream=sys.stdout,
|
||
)
|
||
# Применяем уровень ко всем логгерам приложения
|
||
logging.getLogger("app").setLevel(_level)
|
||
logger = logging.getLogger(__name__)
|
||
logger.info("Backend log level: %s", logging.getLevelName(_level))
|
||
|
||
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 (события, буферы, SMS и т.д.)
|
||
await redis_service.connect()
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Redis not available: {e}")
|
||
|
||
try:
|
||
# Подключаем локальный Redis для сессий (отдельно от внешнего)
|
||
session_redis = await redis.from_url(
|
||
settings.redis_session_url,
|
||
encoding="utf-8",
|
||
decode_responses=True,
|
||
)
|
||
await session_redis.ping()
|
||
session.init_redis(session_redis)
|
||
logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Session 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}")
|
||
|
||
# Postgres LISTEN support_events для доставки сообщений поддержки в реальном времени (SSE)
|
||
support_listener_task = None
|
||
try:
|
||
support_listener_task = asyncio.create_task(support._run_support_listener())
|
||
logger.info("✅ Support NOTIFY listener task started")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Support listener not started: {e}")
|
||
|
||
logger.info("✅ Ticket Form Intake Platform started successfully!")
|
||
|
||
yield
|
||
|
||
# SHUTDOWN
|
||
logger.info("🛑 Shutting down Ticket Form Intake Platform...")
|
||
if support_listener_task and not support_listener_task.done():
|
||
support_listener_task.cancel()
|
||
try:
|
||
await support_listener_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
await db.disconnect()
|
||
await redis_service.disconnect()
|
||
if session.redis_client:
|
||
await session.redis_client.close()
|
||
session.init_redis(None)
|
||
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(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id}
|
||
app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK
|
||
app.include_router(support.router) # 📞 Поддержка: форма из бара и карточек жалоб → n8n
|
||
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)
|
||
|