commit 4de0ccdf87fb1159fd43eb8defb9fc462c1745e5 Author: hbyang Date: Thu Mar 5 15:34:34 2026 +0900 sns scheduler 추가 . diff --git a/.env b/.env new file mode 100644 index 0000000..6201774 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +# 백엔드 내부 API (Docker 네트워크 내부 URL) +BACKEND_INTERNAL_URL=http://localhost:8000 + +# 내부 인증 키 (백엔드 .env의 INTERNAL_SECRET_KEY와 동일하게 설정) +INTERNAL_SECRET_KEY=ado2-internal-backend-server-secret-key + +# MySQL 설정 (백엔드와 동일한 DB) +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=1234 +MYSQL_DB=castad_test1 + +# 체크 주기 (분) +CHECK_INTERVAL_MINUTES=1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa10b48 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# 백엔드 내부 API (Docker 네트워크 내부 URL) +BACKEND_INTERNAL_URL=http://castad-app:8000 + +# 내부 인증 키 (백엔드 .env의 INTERNAL_SECRET_KEY와 동일하게 설정) +INTERNAL_SECRET_KEY=change-me-internal-secret-key + +# MySQL 설정 (백엔드와 동일한 DB) +MYSQL_HOST=mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=1234 +MYSQL_DB=mydb + +# 체크 주기 (분) +CHECK_INTERVAL_MINUTES=1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d44816 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim + +WORKDIR /app + +COPY pyproject.toml . +RUN pip install uv && uv pip install --system -e . + +COPY . . + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cfe212 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# o2o-ado2-scheduler + +SNS 예약 업로드 스케줄러. 주기적으로 DB를 조회하여 예약 시간이 된 업로드 작업을 백엔드 내부 API로 트리거합니다. + +--- + +## 실행 + +### 로컬 + +```bash +pip install uv +uv pip install -e . +python main.py +``` + +### Docker 단독 실행 + +```bash +docker build -t o2o-ado2-scheduler . +docker run --env-file .env o2o-ado2-scheduler +``` + +### Docker Compose (백엔드와 같은 네트워크) + +```yaml +scheduler: + build: . + env_file: .env + networks: + - backend-network + restart: unless-stopped +``` + +> Docker Compose로 실행할 경우 `BACKEND_INTERNAL_URL`은 컨테이너 서비스명으로 설정합니다. +> 예: `BACKEND_INTERNAL_URL=http://castad-app:8000` + +--- + +## 환경변수 (.env) + +`.env.example`을 복사하여 사용합니다. + +```bash +cp .env.example .env +``` + +| 변수 | 설명 | 예시 | +|------|------|------| +| `BACKEND_INTERNAL_URL` | 백엔드 서버 URL | `http://localhost:8000` (로컬) / `http://castad-app:8000` (Docker) | +| `INTERNAL_SECRET_KEY` | 내부 API 인증 키 (백엔드와 동일하게 설정) | `your-secret-key` | +| `MYSQL_HOST` | MySQL 호스트 | `localhost` (로컬) / `mysql` (Docker) | +| `MYSQL_PORT` | MySQL 포트 | `3306` | +| `MYSQL_USER` | MySQL 유저 | `root` | +| `MYSQL_PASSWORD` | MySQL 비밀번호 | `1234` | +| `MYSQL_DB` | MySQL 데이터베이스명 | `castad_test1` | +| `CHECK_INTERVAL_MINUTES` | 예약 업로드 체크 주기 (분) | `10` | diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..d7ac04e Binary files /dev/null and b/__pycache__/config.cpython-314.pyc differ diff --git a/__pycache__/db.cpython-314.pyc b/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000..f8adaca Binary files /dev/null and b/__pycache__/db.cpython-314.pyc differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..c86788d --- /dev/null +++ b/config.py @@ -0,0 +1,45 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent + +_base_config = SettingsConfigDict( + env_file=PROJECT_DIR / ".env", + env_ignore_empty=True, + extra="ignore", +) + + +class SchedulerSettings(BaseSettings): + # 백엔드 내부 API 설정 + BACKEND_INTERNAL_URL: str = Field( + default="http://castad-app:8000", + description="백엔드 서버 내부 URL (Docker 네트워크)", + ) + INTERNAL_SECRET_KEY: str = Field( + default="change-me-internal-secret-key", + description="내부 API 인증 키 (백엔드와 동일해야 함)", + ) + + # MySQL 설정 + MYSQL_HOST: str = Field(default="mysql") + MYSQL_PORT: int = Field(default=3306) + MYSQL_USER: str = Field(default="root") + MYSQL_PASSWORD: str = Field(default="") + MYSQL_DB: str = Field(default="castad_test1") + + # 스케줄러 설정 + CHECK_INTERVAL_MINUTES: int = Field( + default=10, + description="예약 업로드 체크 주기 (분)", + ) + + model_config = _base_config + + @property + def MYSQL_URL(self) -> str: + return f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" + + +settings = SchedulerSettings() diff --git a/db.py b/db.py new file mode 100644 index 0000000..a8f3750 --- /dev/null +++ b/db.py @@ -0,0 +1,19 @@ +""" +DB 연결 (공유 엔진 및 세션 팩토리) +""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from config import settings + +engine = create_async_engine( + url=settings.MYSQL_URL, + pool_pre_ping=True, + pool_recycle=280, +) + +SessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) diff --git a/jobs/__init__.py b/jobs/__init__.py new file mode 100644 index 0000000..55ef114 --- /dev/null +++ b/jobs/__init__.py @@ -0,0 +1,15 @@ +""" +등록된 잡 목록 + +새로운 플랫폼 잡 추가 시 이 목록에 인스턴스를 추가합니다. +예: from jobs.instagram import InstagramUploadJob + from jobs.tiktok import TikTokUploadJob +""" + +from jobs.sns_upload import SnsUploadJob + +JOBS = [ + SnsUploadJob(), + # InstagramUploadJob(), + # TikTokUploadJob(), +] diff --git a/jobs/__pycache__/__init__.cpython-314.pyc b/jobs/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..e79e7c6 Binary files /dev/null and b/jobs/__pycache__/__init__.cpython-314.pyc differ diff --git a/jobs/__pycache__/base.cpython-314.pyc b/jobs/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..bd56247 Binary files /dev/null and b/jobs/__pycache__/base.cpython-314.pyc differ diff --git a/jobs/__pycache__/sns_upload.cpython-314.pyc b/jobs/__pycache__/sns_upload.cpython-314.pyc new file mode 100644 index 0000000..341b4e5 Binary files /dev/null and b/jobs/__pycache__/sns_upload.cpython-314.pyc differ diff --git a/jobs/base.py b/jobs/base.py new file mode 100644 index 0000000..92e12c3 --- /dev/null +++ b/jobs/base.py @@ -0,0 +1,20 @@ +""" +스케줄 잡 추상 베이스 클래스 + +새로운 플랫폼(Instagram, TikTok 등) 추가 시 이 클래스를 상속합니다. +""" + +from abc import ABC, abstractmethod + + +class BaseJob(ABC): + # 스케줄러 로그 및 job ID에 사용되는 이름 + name: str + + # 체크 주기 (분) — None이면 config의 CHECK_INTERVAL_MINUTES 사용 + interval_minutes: int | None = None + + @abstractmethod + async def run(self) -> None: + """주기적으로 실행될 잡 로직""" + ... diff --git a/jobs/sns_upload.py b/jobs/sns_upload.py new file mode 100644 index 0000000..03d91a4 --- /dev/null +++ b/jobs/sns_upload.py @@ -0,0 +1,81 @@ +""" +SNS 예약 업로드 잡 + +scheduled_at이 현재 시간 이전이고 status가 pending인 업로드 작업을 +백엔드 내부 API로 트리거합니다. +""" + +import asyncio +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +import httpx +from sqlalchemy import text + +from config import settings +from db import SessionLocal +from jobs.base import BaseJob + +logger = logging.getLogger(__name__) + + +class SnsUploadJob(BaseJob): + name = "SNS 예약 업로드 체크" + + async def run(self) -> None: + logger.info("[SNS_UPLOAD] 예약 업로드 체크 시작") + + try: + upload_ids = await self._fetch_pending_uploads() + except Exception as e: + logger.error(f"[SNS_UPLOAD] DB 조회 오류: {e}") + return + + if not upload_ids: + logger.info("[SNS_UPLOAD] 실행할 예약 업로드 없음") + return + + logger.info(f"[SNS_UPLOAD] 예약 업로드 {len(upload_ids)}건 발견: {upload_ids}") + + async with httpx.AsyncClient() as client: + tasks = [self._trigger_upload(uid, client) for uid in upload_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + + success = sum(1 for r in results if r is True) + logger.info(f"[SNS_UPLOAD] 완료 - 성공: {success}/{len(upload_ids)}") + + async def _fetch_pending_uploads(self) -> list[int]: + # DB의 다른 datetime 컬럼과 동일하게 Seoul time naive로 비교 + now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + query = text(""" + SELECT id FROM social_upload + WHERE status = 'pending' + AND scheduled_at IS NOT NULL + AND scheduled_at <= :now + """) + async with SessionLocal() as session: + result = await session.execute(query, {"now": now}) + rows = result.fetchall() + return [row[0] for row in rows] + + async def _trigger_upload(self, upload_id: int, client: httpx.AsyncClient) -> bool: + url = f"{settings.BACKEND_INTERNAL_URL}/internal/social/upload/{upload_id}" + try: + response = await client.post( + url, + headers={"X-Internal-Secret": settings.INTERNAL_SECRET_KEY}, + timeout=10.0, + ) + if response.status_code == 200: + logger.info(f"[SNS_UPLOAD] 업로드 트리거 성공 - upload_id: {upload_id}") + return True + else: + logger.error( + f"[SNS_UPLOAD] 업로드 트리거 실패 - upload_id: {upload_id}, " + f"status: {response.status_code}, body: {response.text}" + ) + return False + except httpx.RequestError as e: + logger.error(f"[SNS_UPLOAD] 업로드 트리거 요청 오류 - upload_id: {upload_id}, error: {e}") + return False diff --git a/main.py b/main.py new file mode 100644 index 0000000..a30a12b --- /dev/null +++ b/main.py @@ -0,0 +1,53 @@ +""" +스케줄러 엔트리포인트 + +APScheduler를 사용하여 jobs/ 에 등록된 잡들을 주기적으로 실행합니다. +""" + +import asyncio +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from config import settings +from jobs import JOBS + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(levelname)s] %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +logger = logging.getLogger(__name__) + + +async def main() -> None: + scheduler = AsyncIOScheduler() + + for job in JOBS: + interval = job.interval_minutes or settings.CHECK_INTERVAL_MINUTES + scheduler.add_job( + job.run, + trigger="interval", + minutes=interval, + id=job.name, + name=job.name, + max_instances=1, + ) + logger.info(f"[SCHEDULER] 잡 등록 - '{job.name}' (주기: {interval}분)") + + scheduler.start() + logger.info(f"[SCHEDULER] 시작 - 백엔드: {settings.BACKEND_INTERNAL_URL}") + + # 시작 시 모든 잡 즉시 1회 실행 + await asyncio.gather(*[job.run() for job in JOBS]) + + try: + await asyncio.Event().wait() + except (KeyboardInterrupt, SystemExit): + logger.info("[SCHEDULER] 종료 중...") + scheduler.shutdown() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28c4a58 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "o2o-ado2-scheduler" +version = "0.1.0" +description = "SNS 업로드 스케줄러" +requires-python = ">=3.13" +dependencies = [ + "apscheduler>=3.10.4", + "httpx>=0.27.0", + "sqlalchemy>=2.0.0", + "aiomysql>=0.2.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-dotenv>=1.0.0", +]