Initial project upload
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_socketio import SocketIO
|
||||
from flask_wtf import CSRFProtect
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from config import Config
|
||||
from backend.models.user import db, load_user
|
||||
from backend.routes import register_routes
|
||||
from backend.services.logger import setup_logging
|
||||
from backend.services import watchdog_handler
|
||||
|
||||
# 텔레그램 서비스 (별도 모듈)에서 가져옴
|
||||
from backend.services.telegram_bot_service import run_polling as telegram_run_polling
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# .env 파일 로드 (환경변수 우선순위 보장)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
load_dotenv()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 템플릿/정적 경로를 파일 위치 기준으로 안전하게 설정
|
||||
# structure: <project_root>/backend/templates, <project_root>/backend/static
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATE_DIR = (BASE_DIR / "backend" / "templates").resolve()
|
||||
STATIC_DIR = (BASE_DIR / "backend" / "static").resolve()
|
||||
|
||||
# Flask 애플리케이션 생성
|
||||
app = Flask(__name__, template_folder=str(TEMPLATE_DIR), static_folder=str(STATIC_DIR))
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 로그 설정
|
||||
setup_logging(app)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CSRF 보호 + 템플릿에서 {{ csrf_token() }} 사용 가능하게 주입
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
csrf = CSRFProtect()
|
||||
csrf.init_app(app)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ProxyFix: Nginx/NPM 등 리버스 프록시 뒤에서 실행 시 헤더 신뢰
|
||||
# (HTTPS 인식, 올바른 IP/Scheme 파악으로 CSRF/세션 문제 해결)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_csrf():
|
||||
try:
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
return dict(csrf_token=generate_csrf)
|
||||
except Exception:
|
||||
# Flask-WTF 미설치/에러 시에도 앱이 뜨도록 방어
|
||||
return dict(csrf_token=lambda: "")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# SocketIO: Windows 기본 threading, Linux는 eventlet 설치 시 eventlet 사용
|
||||
# 환경변수 SOCKETIO_ASYNC_MODE 로 강제 지정 가능 ("threading"/"eventlet"/"gevent"/"auto")
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
async_mode = (app.config.get("SOCKETIO_ASYNC_MODE") or "threading").lower()
|
||||
if async_mode == "auto":
|
||||
async_mode = "threading"
|
||||
|
||||
if async_mode == "eventlet":
|
||||
# Windows에선 eventlet 비권장, Linux에서만 시도
|
||||
if platform.system() != "Windows":
|
||||
try:
|
||||
import eventlet # type: ignore
|
||||
eventlet.monkey_patch()
|
||||
except Exception:
|
||||
async_mode = "threading" # 폴백
|
||||
else:
|
||||
async_mode = "threading"
|
||||
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode=async_mode)
|
||||
|
||||
# watchdog에서 socketio 사용
|
||||
watchdog_handler.socketio = socketio
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DB / 마이그레이션
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
app.logger.debug("DB URI = %s", app.config.get("SQLALCHEMY_DATABASE_URI"))
|
||||
db.init_app(app)
|
||||
Migrate(app, db)
|
||||
|
||||
# (선택) 개발 편의용: 테이블 자동 부트스트랩
|
||||
# 환경변수 AUTO_BOOTSTRAP_DB=true 일 때만 동작 (운영에서는 flask db upgrade 사용 권장)
|
||||
if os.getenv("AUTO_BOOTSTRAP_DB", "false").lower() == "true":
|
||||
from sqlalchemy import inspect
|
||||
|
||||
with app.app_context():
|
||||
insp = inspect(db.engine)
|
||||
if "user" not in insp.get_table_names():
|
||||
db.create_all()
|
||||
app.logger.info("DB bootstrap: created tables via create_all()")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Login
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "auth.login"
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def _load_user(user_id: str):
|
||||
return load_user(user_id)
|
||||
|
||||
|
||||
# 라우트 등록 (Blueprints 등)
|
||||
register_routes(app, socketio)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 텔레그램 봇 폴링 서비스 (중복 실행 방지 포함)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
_bot_socket_lock = None
|
||||
|
||||
def start_telegram_bot_polling() -> None:
|
||||
"""텔레그램 봇 폴링을 백그라운드 스레드로 시작 (TCP 소켓 락으로 중복 방지)"""
|
||||
import threading
|
||||
import socket
|
||||
|
||||
global _bot_socket_lock
|
||||
|
||||
if _bot_socket_lock:
|
||||
return
|
||||
|
||||
app.logger.debug("🔒 봇 중복 실행 방지 락(TCP:50000) 획득 시도...")
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 50000))
|
||||
s.listen(1)
|
||||
_bot_socket_lock = s
|
||||
app.logger.debug("🔒 락 획득 성공! 봇 폴링 스레드를 시작합니다.")
|
||||
except OSError:
|
||||
app.logger.warning("⛔ 락 획득 실패: 이미 다른 프로세스(또는 좀비 프로세스)가 포트 50000을 점유 중입니다. 봇 폴링을 건너뜁니다.")
|
||||
return
|
||||
|
||||
def _runner():
|
||||
try:
|
||||
telegram_run_polling(app)
|
||||
except Exception as e:
|
||||
app.logger.error("텔레그램 봇 폴링 서비스 오류: %s", e)
|
||||
|
||||
polling_thread = threading.Thread(target=_runner, daemon=True)
|
||||
polling_thread.start()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 텔레그램 봇 폴링 자동 시작
|
||||
# Flask 앱이 초기화되면 자동으로 봇 폴링 시작
|
||||
# 주의: Flask 리로더(Debug 모드) 사용 시 메인/워커 프로세스 중복 실행 방지
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 1. 리로더의 워커 프로세스인 경우 (WERKZEUG_RUN_MAIN = "true")
|
||||
# 2. 또는 디버그 모드가 꺼진 경우 (Production)
|
||||
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not app.config.get("DEBUG"):
|
||||
start_telegram_bot_polling()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 엔트리포인트
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("FLASK_PORT", 6050))
|
||||
debug = os.getenv("FLASK_DEBUG", "true").lower() == "true"
|
||||
|
||||
# python app.py로 직접 실행 시(use_reloader=False)에는 위 조건문에서 실행되지 않을 수 있으므로
|
||||
# 여기서 명시적으로 실행 (중복 실행 방지 플래그가 있어 안전함)
|
||||
start_telegram_bot_polling()
|
||||
|
||||
socketio.run(app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True, use_reloader=False)
|
||||
Reference in New Issue
Block a user