| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- """中国节假日日历同步(ailcc batch)与进程内后台循环"""
- from __future__ import annotations
- import calendar
- import json
- import logging
- import threading
- import time
- import uuid
- from datetime import date, datetime, timedelta
- from typing import Any, Callable, Dict, List, Optional, Tuple
- from urllib.parse import urlencode
- from urllib.request import Request, urlopen
- from app.core.config import settings
- from app.models.cn_holiday import SysCnHolidayDay
- logger = logging.getLogger("sqxp.cn_holiday_sync")
- _sync_lock = threading.Lock()
- _stop_event: Optional[threading.Event] = None
- _thread: Optional[threading.Thread] = None
- _loop_lock = threading.Lock()
- def _is_rest_from_type(day_type: int) -> int:
- return 1 if int(day_type) in (1, 2, 3) else 0
- def map_ailcc_day_row(
- date_str: str,
- holiday: Optional[Dict[str, Any]],
- type_info: Optional[Dict[str, Any]],
- ) -> Dict[str, Any]:
- """Normalize one day for upsert fields."""
- d = datetime.strptime(date_str, "%Y-%m-%d").date()
- if type_info and type_info.get("type") is not None:
- day_type = int(type_info["type"])
- day_name = type_info.get("name") or (holiday or {}).get("name")
- week = type_info.get("week")
- if week is None:
- week = d.isoweekday()
- else:
- week = int(week)
- else:
- # Infer from weekday: Sat/Sun → weekend(1), else workday(0)
- is_weekend = d.weekday() >= 5
- day_type = 1 if is_weekend else 0
- day_name = (holiday or {}).get("name") if holiday else None
- week = d.isoweekday()
- raw = {"date": date_str, "holiday": holiday, "type": type_info}
- return {
- "day_date": d,
- "day_type": day_type,
- "day_name": day_name,
- "week": week,
- "is_rest_day": _is_rest_from_type(day_type),
- "raw_json": json.dumps(raw, ensure_ascii=False),
- }
- def iter_month_dates(year: int, month: int) -> List[str]:
- _, last = calendar.monthrange(year, month)
- return [date(year, month, day).isoformat() for day in range(1, last + 1)]
- def _years_to_sync(today: Optional[date] = None) -> List[int]:
- today = today or date.today()
- return [today.year, today.year + 1]
- def _months_for_year(year: int) -> List[Tuple[int, int]]:
- return [(year, m) for m in range(1, 13)]
- def _fetch_batch(date_strs: List[str]) -> Dict[str, Any]:
- """GET batch API with retries. Raises on persistent failure."""
- base = (settings.CN_HOLIDAY_API_BASE or "").rstrip("/")
- params = urlencode({"d": ",".join(date_strs), "type": "Y"})
- url = f"{base}/api/holiday/batch?{params}"
- timeout = int(settings.CN_HOLIDAY_HTTP_TIMEOUT_SEC or 15)
- retries = max(0, int(settings.CN_HOLIDAY_HTTP_RETRIES or 0))
- last_err: Optional[BaseException] = None
- for attempt in range(retries + 1):
- try:
- req = Request(url, method="GET", headers={"Accept": "application/json"})
- with urlopen(req, timeout=timeout) as resp:
- body = resp.read().decode("utf-8")
- data = json.loads(body)
- if not isinstance(data, dict) or int(data.get("code", -1)) != 0:
- raise RuntimeError(f"ailcc batch unexpected response: {body[:500]}")
- return data
- except Exception as exc: # noqa: BLE001 — retry then raise
- last_err = exc
- logger.warning(
- "cn_holiday batch fetch failed attempt=%s/%s: %s",
- attempt + 1,
- retries + 1,
- exc,
- )
- if attempt < retries:
- time.sleep(0.5 * (attempt + 1))
- assert last_err is not None
- raise last_err
- def _days_in_year(year: int) -> int:
- return 366 if calendar.isleap(year) else 365
- def is_year_calendar_complete(session: Any, year: int) -> bool:
- """True when the year already has one row per calendar day."""
- start = date(year, 1, 1)
- end = date(year, 12, 31)
- cnt = (
- session.query(SysCnHolidayDay)
- .filter(SysCnHolidayDay.day_date >= start, SysCnHolidayDay.day_date <= end)
- .count()
- )
- return int(cnt) >= _days_in_year(year)
- def sync_cn_holidays(db_session_factory: Callable[[], Any]) -> int:
- """Fetch current + next year month-by-month; upsert by day_date. Return rows written.
- Skip any year that already has a full calendar (row count >= days in that year).
- """
- with _sync_lock:
- written = 0
- session = db_session_factory()
- try:
- now = datetime.now()
- for year in _years_to_sync():
- if is_year_calendar_complete(session, year):
- logger.info("cn_holiday skip year=%s (calendar already complete)", year)
- continue
- for y, month in _months_for_year(year):
- date_strs = iter_month_dates(y, month)
- payload = _fetch_batch(date_strs)
- holiday_map = payload.get("holiday") or {}
- type_map = payload.get("type") or {}
- for ds in date_strs:
- holiday = holiday_map.get(ds)
- type_info = type_map.get(ds)
- mapped = map_ailcc_day_row(ds, holiday, type_info)
- existing = (
- session.query(SysCnHolidayDay)
- .filter(SysCnHolidayDay.day_date == mapped["day_date"])
- .first()
- )
- if existing is None:
- row = SysCnHolidayDay(
- id=uuid.uuid4().hex,
- day_date=mapped["day_date"],
- day_type=mapped["day_type"],
- day_name=mapped["day_name"],
- week=mapped["week"],
- is_rest_day=mapped["is_rest_day"],
- raw_json=mapped["raw_json"],
- sync_time=now,
- create_time=now,
- update_time=now,
- )
- session.add(row)
- else:
- existing.day_type = mapped["day_type"]
- existing.day_name = mapped["day_name"]
- existing.week = mapped["week"]
- existing.is_rest_day = mapped["is_rest_day"]
- existing.raw_json = mapped["raw_json"]
- existing.sync_time = now
- existing.update_time = now
- written += 1
- session.commit()
- logger.info("cn_holiday sync ok rows=%s", written)
- return written
- except Exception:
- session.rollback()
- logger.exception("cn_holiday sync failed")
- raise
- finally:
- session.close()
- def seconds_until_next_local_midnight(now: Optional[datetime] = None) -> float:
- now = now or datetime.now()
- # Always wait until the next calendar midnight (if already 00:00:00 → +1 day)
- nxt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
- return max(0.0, (nxt - now).total_seconds())
- def _loop_body(db_session_factory: Callable[[], Any], stop_event: threading.Event) -> None:
- try:
- sync_cn_holidays(db_session_factory)
- except Exception:
- logger.exception("cn_holiday initial sync failed; will retry at next midnight")
- while not stop_event.is_set():
- wait_sec = seconds_until_next_local_midnight()
- # Wait in chunks so stop responds quickly
- end_at = time.time() + wait_sec
- while not stop_event.is_set():
- remaining = end_at - time.time()
- if remaining <= 0:
- break
- stop_event.wait(timeout=min(remaining, 1.0))
- if stop_event.is_set():
- break
- try:
- sync_cn_holidays(db_session_factory)
- except Exception:
- logger.exception("cn_holiday midnight sync failed; keep old cache")
- def start_cn_holiday_sync_loop(db_session_factory: Callable[[], Any]) -> None:
- """Start daemon thread if not running: sync immediately, then each local midnight."""
- global _stop_event, _thread
- with _loop_lock:
- if _thread is not None and _thread.is_alive():
- return
- _stop_event = threading.Event()
- t = threading.Thread(
- target=_loop_body,
- args=(db_session_factory, _stop_event),
- name="cn-holiday-sync",
- daemon=True,
- )
- _thread = t
- t.start()
- def stop_cn_holiday_sync_loop(timeout: float = 2.0) -> None:
- """Set stop event and join the sync thread."""
- global _stop_event, _thread
- with _loop_lock:
- ev = _stop_event
- t = _thread
- if ev is not None:
- ev.set()
- if t is not None and t.is_alive():
- t.join(timeout=timeout)
- _thread = None
- _stop_event = None
|