From 4de0ccdf87fb1159fd43eb8defb9fc462c1745e5 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 5 Mar 2026 15:34:34 +0900 Subject: [PATCH] =?UTF-8?q?sns=20scheduler=20=EC=B6=94=EA=B0=80=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 15 ++++ .env.example | 15 ++++ Dockerfile | 10 +++ README.md | 57 ++++++++++++++ __pycache__/config.cpython-314.pyc | Bin 0 -> 2617 bytes __pycache__/db.cpython-314.pyc | Bin 0 -> 612 bytes config.py | 45 +++++++++++ db.py | 19 +++++ jobs/__init__.py | 15 ++++ jobs/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 481 bytes jobs/__pycache__/base.cpython-314.pyc | Bin 0 -> 1302 bytes jobs/__pycache__/sns_upload.cpython-314.pyc | Bin 0 -> 6121 bytes jobs/base.py | 20 +++++ jobs/sns_upload.py | 81 ++++++++++++++++++++ main.py | 53 +++++++++++++ pyproject.toml | 14 ++++ 16 files changed, 344 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __pycache__/config.cpython-314.pyc create mode 100644 __pycache__/db.cpython-314.pyc create mode 100644 config.py create mode 100644 db.py create mode 100644 jobs/__init__.py create mode 100644 jobs/__pycache__/__init__.cpython-314.pyc create mode 100644 jobs/__pycache__/base.cpython-314.pyc create mode 100644 jobs/__pycache__/sns_upload.cpython-314.pyc create mode 100644 jobs/base.py create mode 100644 jobs/sns_upload.py create mode 100644 main.py create mode 100644 pyproject.toml 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 0000000000000000000000000000000000000000..d7ac04ee74c3f4f5f8edccbc6bb6d200cebb3e79 GIT binary patch literal 2617 zcmZ`*U2M}<6u!=1-84y?F0`ez)GdF_N*W4fAcla_22x<9IHl9Xf@Ko7jd1L6eZ$s@ zLB=`_ZId=_Lx2WS(Gc6DJurk25A2;iEft;GU1{3bo}j5jRwQ`bxlU5DvH9WnJKs6? z+V?y69=pTib|Sb&PhL+Pu_N>+b4r(^2o`Sx$e^Q0W3e(x^A!P@6s2@z-12IHNgZGBaVLrAoX4zXg3wZM?7 zWvCU2d_58?qt@;c>x#NsQE6`C7M-h*zv>|y;^(M+PeM`SehYQ&mJKDM5R%YF4O&}5 zy}CA%80|~M2qfDEWimEl>r}LpL)4KN)pV@*t&|5}8cC=MtzoFZOG+|D#;IL-n_xNi z{FM;`JwV5SOpdL;s41y1 z8NQ6+O{TJpsWcg98DlCdR+TYTEa#|Gbd^2L6?GF&w0anA;zV~nQhCt>wq{A!8Z%PO zBE&1!klJBHUJ|{abuINqVq;1?ttz-A>qOlcA*pS_U`#fM91qB;)V8e~Hf%_D!Gh;+ ze4o4U#r)@&h1}%j`J0o%{MlRcw==?F@qp0Yr^k*fSeT!@w(#Md+?O9Oy!)LW%2yFr zM&z_gXjNP>VmOf^30<3Lj*ZFMs1isjfrLgBtjTJ?P-0jif#b?}dNX@iVeX#6ej#_| zPVV{@Vd3mGp?&Eca+fl~{3n-kSMDyHzm+?GS6Ddzji1_+h7aEH{e;fC;c&2!(tr~ zt2*1givpPKTm}uJqn53c2*rVILWj69PUIro5ftP=U&}+5PaUwTG}d67S`C6h632*+ zlQN-hNs6hmVZ>n*OOnBo+cI=l@UWp^BbbzNLQ}>;reL3994C4z_?C{32V;6%3F;g5 zfE?F128>bxfoBJzGc`_Kk|b-IPGq7;5?%R1jXTTZet4=;59V10Jw=@T43{_UrA>wV zB7~4B0t4(~4G5OYFP;OCK_ZAS$oPLDIS#aAVe$>v6XWLTpUwsWMv5uvBDU> zj4DDG+Q=G*ZNh7ozC%vvCV)Lmxowo&MY*8gPMxp45jk`~Ivfd!)KkENJ(0-k2gSZp zEPOCBRKkM?#UaWU^7?kO7Go&th`~0(TD%-Att?I`9-u2;lz1uF4!OoYFd6i_w{5EX z>z!A3X4^L1=(;g9yE#0)Ih^h8zvsQz{nO4LcV_n;$sTz#8U31izM0G>wXAN; z>ZhjlQ(5huZ1w3Ze;PNzbpP0wOiFWKP5}7teHlb-U%qM)xwVi}#FjE_En*IGY-PN? z3_HqjMOi-2FchH@`v6ei?w;QLp@BZBe_$vi4)lQRpwdfAUL@2jhK8j5p*Qd<$j7Uh zX=SF3nKfW&U2izlyT35QFwF2;|G?p)P{eP+bu4U!wgH%JT-*SrczVpz;6}!-0OMhi z6JVZwfX<=^jm>#vwL9{5#Bq5SD!FdvHXyHW+O|HwoH>oA;#Qc`E_7BC#r6KPp3 zMye(FPGN(naO5>zk}#_#ewmpLX8unmydI)3*D?4;N`($UUGNM=EqQzj>nR13alDSD zddtrKNLq`*n=FIg1HB>A1DE+dMH4B)70LXy7ifTztpXm>YJ(a z7to%Wx(+~%E#FvX8apxrzxb9wm)$R}hD{BCzY zwXp8BXhjasV-r5z0)==FnWZpaa=`DQd}=%{Yp_~VV(Klql2QD{!0a$#_AfvyQt(@$ zCXQhT^PrCGUjn45z>1-(Cl%^Q$ym|g+cqd3+!yK{lKT2Zyorrxnoxde&Q8X$V_WDQ z{Pw~=*nxY1V|p6HU>;jJj{6lg{)Tq^iW(kxYCqljkv8jDJ?&Y2-?Mhs6PWe{GF1<% yTj$&j7hn0=-8#2y`JA)%NrkP(l5supG;wFRi>@b#-Saz;tNDJ_%Co%r+`j>`)S;39 literal 0 HcmV?d00001 diff --git a/__pycache__/db.cpython-314.pyc b/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8adacaf50b76ff7b6806006aae63d2799f1d613 GIT binary patch literal 612 zcmYLF&ubGw6rTN&%_gQbwzf(X8uij3%}Os`il78ZA&PD-*h3t)yVJO4cW0fMU~?-{ zJt-h>s5y89%^I?iKo5|u%qvY*s7+01O>C+b)Y!n=aT))UIQmPv3P2b0N*}H8ufe5FhadlwX>nDEq3a=zmP4EGgUQ{!eu7 wd*OUymcN;mZL^Y$O(v&K|IX`0bq4LF~a56b@CYh)JSyrEx*f<#d1%geu%m4rY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e79e7c66ca01f4a3843ad9a05eb5df5e20bf76b3 GIT binary patch literal 481 zcmYL`-zx-B6vywK9jwXEKR~XO+GMBXN%A1YdY~4M+{VsejF~&#JDc`x!^373g-EC^ z%JM>O^59wihI#eo-Yt@MceY*5!|8m#=X1}ga|b)xH6Y_v+}cYJfcHo=La7(u1G312 zGzh>lNW&DIRsy9LEGd0pq>dzE5KAllAQ8*HNlW8LkGyor4=<3oDj|MXmP*saPJx#$ z#CZi(kN5f2ef8j_))!C59}jsXiW0x9)NyfoDAlvryRPn5Wac*#u7TB)GA|Z*@j9uA zv%)A^rNl)WWQ8Sdj|IjWHC$x7KA{0hHH<}nlNjagX9_S~?jfLxxF)hgfFaGP9c&)uYKe#@bOD(7Y6eZ+JF}lLHLh%{v_%rXh|{gb literal 0 HcmV?d00001 diff --git a/jobs/__pycache__/base.cpython-314.pyc b/jobs/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd562478ff983d3f736095ab58ba0987c6f621af GIT binary patch literal 1302 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 literal 0 HcmV?d00001 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", +]