Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
217 lines
7.4 KiB
Python
217 lines
7.4 KiB
Python
"""Точка входа FastAPI-приложения LeadRadar.
|
|
|
|
Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
или: python -m app.main
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import config
|
|
from .auth import ensure_creds
|
|
from .db import store
|
|
from .routers import (
|
|
auth_routes,
|
|
dashboard_routes,
|
|
discovery_routes,
|
|
events_routes,
|
|
ml_routes,
|
|
processing_routes,
|
|
projects_routes,
|
|
settings_routes,
|
|
tg_routes,
|
|
)
|
|
from .services import fts as fts_svc
|
|
from .services import ml_client
|
|
from .services import projects as proj_svc
|
|
from .services import rates as rates_svc
|
|
from .services.leads import notify_tick_stats, tick_storage
|
|
from .services.telegram import register_main_loop, tg
|
|
|
|
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
log = logging.getLogger("leadradar")
|
|
|
|
|
|
async def _storage_loop() -> None:
|
|
"""Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram."""
|
|
while True:
|
|
try:
|
|
stats = tick_storage()
|
|
await notify_tick_stats(stats)
|
|
await proj_svc.check_reminders()
|
|
await tg.heartbeat()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("storage loop error")
|
|
await asyncio.sleep(30)
|
|
|
|
|
|
async def _fts_loop() -> None:
|
|
"""Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок)."""
|
|
while True:
|
|
await asyncio.sleep(24 * 3600)
|
|
try:
|
|
fts_svc.rebuild()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("fts rebuild error")
|
|
|
|
|
|
async def _rates_loop() -> None:
|
|
"""Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов."""
|
|
while True:
|
|
try:
|
|
if rates_svc.should_fetch():
|
|
ok = await rates_svc.refresh_rates()
|
|
if not ok:
|
|
log.warning("rates fetch failed (повтор через 30 минут)")
|
|
except Exception: # noqa: BLE001
|
|
log.exception("rates loop error")
|
|
await asyncio.sleep(30 * 60)
|
|
|
|
|
|
async def _pipeline_loop() -> None:
|
|
"""Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды."""
|
|
from .services.pipeline import pump_once
|
|
|
|
while True:
|
|
try:
|
|
await pump_once()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("pipeline worker error")
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
async def _discovery_loop() -> None:
|
|
"""Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c)."""
|
|
from .services.discovery_worker import tick
|
|
|
|
while True:
|
|
try:
|
|
await tick()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("discovery worker error")
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
async def _ml_sync_loop() -> None:
|
|
"""Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c)."""
|
|
while True:
|
|
try:
|
|
await ml_client.flush_outbox()
|
|
await ml_client.refresh_status()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("ml sync error")
|
|
await asyncio.sleep(10)
|
|
|
|
|
|
async def _suggest_loop() -> None:
|
|
"""Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин)."""
|
|
from .services.suggest import suggest_from_inbox
|
|
|
|
while True:
|
|
try:
|
|
await suggest_from_inbox(force=False)
|
|
except Exception: # noqa: BLE001
|
|
log.exception("suggest loop error")
|
|
await asyncio.sleep(180)
|
|
|
|
|
|
async def _tg_sweep_loop() -> None:
|
|
"""Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам
|
|
(событие могло потеряться при рестарте/разрыве соединения)."""
|
|
from .services.telegram import tg as tg_svc
|
|
|
|
while True:
|
|
await asyncio.sleep(30)
|
|
try:
|
|
await tg_svc.realtime_sweep()
|
|
except Exception: # noqa: BLE001
|
|
log.exception("tg realtime sweep error")
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
config.ensure_dirs()
|
|
store.init()
|
|
ensure_creds()
|
|
register_main_loop(asyncio.get_running_loop())
|
|
tasks = [
|
|
asyncio.create_task(_storage_loop()),
|
|
asyncio.create_task(_rates_loop()),
|
|
asyncio.create_task(_fts_loop()),
|
|
asyncio.create_task(_pipeline_loop()),
|
|
asyncio.create_task(_discovery_loop()),
|
|
asyncio.create_task(_ml_sync_loop()),
|
|
asyncio.create_task(_suggest_loop()),
|
|
asyncio.create_task(_tg_sweep_loop()),
|
|
asyncio.create_task(tg.auto_resume()),
|
|
]
|
|
# полнотекстовый поиск: пробуем установить расширение и собрать индекс
|
|
if fts_svc.install_extension():
|
|
fts_svc.rebuild()
|
|
# первичное обновление курсов из ЦБ (если источник cbr)
|
|
if store.get_setting("rateSource") == "cbr":
|
|
try:
|
|
await rates_svc.refresh_rates()
|
|
except Exception: # noqa: BLE001
|
|
log.warning("initial rates fetch failed; продолжаем с мок-курсами")
|
|
try:
|
|
yield
|
|
finally:
|
|
for t in tasks:
|
|
t.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
await tg.disconnect()
|
|
store.close()
|
|
|
|
|
|
app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# API
|
|
for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes):
|
|
app.include_router(mod.router)
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health() -> dict:
|
|
return {"ok": True, "service": "leadradar"}
|
|
|
|
|
|
# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно.
|
|
_DIST = config.FRONTEND_DIST
|
|
if _DIST.is_dir():
|
|
assets = _DIST / "assets"
|
|
if assets.is_dir():
|
|
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
|
|
|
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
async def spa(full_path: str):
|
|
candidate = (_DIST / full_path).resolve()
|
|
if full_path and candidate.is_file() and candidate.is_relative_to(_DIST):
|
|
return FileResponse(str(candidate))
|
|
index = _DIST / "index.html"
|
|
if index.exists():
|
|
return FileResponse(str(index))
|
|
return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False)
|