import logging from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.config import settings from app.database import SessionLocal from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog from app.services import habit_service, log_service, push_service logger = logging.getLogger(__name__) scheduler = BackgroundScheduler(timezone=settings.timezone) def _claim_notification_slot(db, habit_id: int, notify_date) -> bool: """habit_notification_log에 (habit_id, notify_date) 행을 먼저 "선점"한다. (habit_id, notify_date) 유니크 제약을 경합 방지용 락으로 쓴다 — reload로 겹친 워커나 APScheduler가 중복 기동된 상황에서도 두 프로세스가 동시에 같은 알림을 보내지 않도록, 실제 발송 전에 먼저 이 행을 커밋해서 선점에 성공한 쪽만 발송하게 한다. """ db.add(HabitNotificationLog(habit_id=habit_id, notify_date=notify_date)) try: db.commit() return True except IntegrityError: db.rollback() return False def _claim_summary_slot(db: Session, user_id: int, period_type: str, period_start: date) -> bool: """summary_notification_log에 (user_id, period_type, period_start) 행을 선점한다. _claim_notification_slot과 같은 목적 — 리로드로 겹친 워커나 중복 기동된 스케줄러가 같은 주간/월간 요약을 두 번 보내지 않도록 유니크 제약을 경합 방지 락으로 쓴다. """ db.add(SummaryNotificationLog(user_id=user_id, period_type=period_type, period_start=period_start)) try: db.commit() return True except IntegrityError: db.rollback() return False def _tick() -> None: """매분 실행되어, 지금 이 순간이 알람 시각+요일에 맞는 active 습관에 대해 오늘 아직 안 보낸 알림만 골라 발송한다.""" db = SessionLocal() try: now = datetime.now(ZoneInfo(settings.timezone)) today = now.date() weekday = today.weekday() habits = habit_service.list_active_habits_with_reminders(db) for habit in habits: if habit.user_id is None or not habit.is_scheduled_on(weekday): continue if habit.reminder_time.hour != now.hour or habit.reminder_time.minute != now.minute: continue already_sent = db.scalar( select(HabitNotificationLog).where( HabitNotificationLog.habit_id == habit.id, HabitNotificationLog.notify_date == today, ) ) if already_sent: continue try: claimed = _claim_notification_slot(db, habit.id, today) if not claimed: continue push_service.send_to_user( db, habit.user_id, title=habit.name, body="지금 실천할 시간이에요", url="/today" ) except Exception: logger.exception("습관(id=%s) 알림 발송 중 오류", habit.id) db.rollback() except Exception: logger.exception("습관 알림 tick 처리 중 오류") finally: db.close() def _send_period_summaries( db: Session, *, period_type: str, period_start: date, range_start: date, range_end: date, title: str, url: str ) -> None: """유저별로 [range_start, range_end] 완료율을 계산해 요약 푸시를 보낸다 (주간/월간 tick 공통 로직). 예정된 습관이 하나도 없던(scheduled == 0) 유저에게는 의미 없는 알림을 보내지 않고 건너뛴다. """ for user_id in habit_service.list_active_user_ids(db): rate, scheduled, checked = log_service.get_period_completion_rate(db, user_id, range_start, range_end) if scheduled == 0: continue try: if not _claim_summary_slot(db, user_id, period_type, period_start): continue push_service.send_to_user( db, user_id, title=title, body=f"완료율 {rate}% ({checked}/{scheduled})이에요.", url=url ) except Exception: logger.exception("유저(id=%s) %s 요약 알림 발송 중 오류", user_id, period_type) db.rollback() def _weekly_summary_tick() -> None: """매주 일요일 21시에 실행되어, 이번 주(월요일~오늘)의 완료율을 유저별로 요약해 발송한다.""" db = SessionLocal() try: today = datetime.now(ZoneInfo(settings.timezone)).date() week_start = today - timedelta(days=today.weekday()) _send_period_summaries( db, period_type="weekly", period_start=week_start, range_start=week_start, range_end=today, title="이번 주 습관 리포트", url=f"/history?view=week&start={week_start.isoformat()}", ) except Exception: logger.exception("주간 요약 tick 처리 중 오류") finally: db.close() def _monthly_summary_tick() -> None: """매월 마지막 날 21:30에 실행되어, 이번 달(1일~오늘)의 완료율을 유저별로 요약해 발송한다.""" db = SessionLocal() try: today = datetime.now(ZoneInfo(settings.timezone)).date() month_start = today.replace(day=1) _send_period_summaries( db, period_type="monthly", period_start=month_start, range_start=month_start, range_end=today, title="이번 달 습관 리포트", url=f"/history?view=month&year={today.year}&month={today.month}", ) except Exception: logger.exception("월간 요약 tick 처리 중 오류") finally: db.close() def start_scheduler() -> None: scheduler.add_job(_tick, "cron", minute="*", id="habit_reminder_tick", replace_existing=True) scheduler.add_job( _weekly_summary_tick, "cron", day_of_week="sun", hour=21, minute=0, id="weekly_summary_tick", replace_existing=True, ) scheduler.add_job( _monthly_summary_tick, "cron", day="last", hour=21, minute=30, id="monthly_summary_tick", replace_existing=True, ) scheduler.start() def shutdown_scheduler() -> None: scheduler.shutdown(wait=False)