Files
zkxw/backend/app/services/cn_holiday_sync.py
T
2026-08-11 11:13:01 +08:00

244 lines
9.0 KiB
Python

"""中国节假日日历同步(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