cn_holiday_sync.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """中国节假日日历同步(ailcc batch)与进程内后台循环"""
  2. from __future__ import annotations
  3. import calendar
  4. import json
  5. import logging
  6. import threading
  7. import time
  8. import uuid
  9. from datetime import date, datetime, timedelta
  10. from typing import Any, Callable, Dict, List, Optional, Tuple
  11. from urllib.parse import urlencode
  12. from urllib.request import Request, urlopen
  13. from app.core.config import settings
  14. from app.models.cn_holiday import SysCnHolidayDay
  15. logger = logging.getLogger("sqxp.cn_holiday_sync")
  16. _sync_lock = threading.Lock()
  17. _stop_event: Optional[threading.Event] = None
  18. _thread: Optional[threading.Thread] = None
  19. _loop_lock = threading.Lock()
  20. def _is_rest_from_type(day_type: int) -> int:
  21. return 1 if int(day_type) in (1, 2, 3) else 0
  22. def map_ailcc_day_row(
  23. date_str: str,
  24. holiday: Optional[Dict[str, Any]],
  25. type_info: Optional[Dict[str, Any]],
  26. ) -> Dict[str, Any]:
  27. """Normalize one day for upsert fields."""
  28. d = datetime.strptime(date_str, "%Y-%m-%d").date()
  29. if type_info and type_info.get("type") is not None:
  30. day_type = int(type_info["type"])
  31. day_name = type_info.get("name") or (holiday or {}).get("name")
  32. week = type_info.get("week")
  33. if week is None:
  34. week = d.isoweekday()
  35. else:
  36. week = int(week)
  37. else:
  38. # Infer from weekday: Sat/Sun → weekend(1), else workday(0)
  39. is_weekend = d.weekday() >= 5
  40. day_type = 1 if is_weekend else 0
  41. day_name = (holiday or {}).get("name") if holiday else None
  42. week = d.isoweekday()
  43. raw = {"date": date_str, "holiday": holiday, "type": type_info}
  44. return {
  45. "day_date": d,
  46. "day_type": day_type,
  47. "day_name": day_name,
  48. "week": week,
  49. "is_rest_day": _is_rest_from_type(day_type),
  50. "raw_json": json.dumps(raw, ensure_ascii=False),
  51. }
  52. def iter_month_dates(year: int, month: int) -> List[str]:
  53. _, last = calendar.monthrange(year, month)
  54. return [date(year, month, day).isoformat() for day in range(1, last + 1)]
  55. def _years_to_sync(today: Optional[date] = None) -> List[int]:
  56. today = today or date.today()
  57. return [today.year, today.year + 1]
  58. def _months_for_year(year: int) -> List[Tuple[int, int]]:
  59. return [(year, m) for m in range(1, 13)]
  60. def _fetch_batch(date_strs: List[str]) -> Dict[str, Any]:
  61. """GET batch API with retries. Raises on persistent failure."""
  62. base = (settings.CN_HOLIDAY_API_BASE or "").rstrip("/")
  63. params = urlencode({"d": ",".join(date_strs), "type": "Y"})
  64. url = f"{base}/api/holiday/batch?{params}"
  65. timeout = int(settings.CN_HOLIDAY_HTTP_TIMEOUT_SEC or 15)
  66. retries = max(0, int(settings.CN_HOLIDAY_HTTP_RETRIES or 0))
  67. last_err: Optional[BaseException] = None
  68. for attempt in range(retries + 1):
  69. try:
  70. req = Request(url, method="GET", headers={"Accept": "application/json"})
  71. with urlopen(req, timeout=timeout) as resp:
  72. body = resp.read().decode("utf-8")
  73. data = json.loads(body)
  74. if not isinstance(data, dict) or int(data.get("code", -1)) != 0:
  75. raise RuntimeError(f"ailcc batch unexpected response: {body[:500]}")
  76. return data
  77. except Exception as exc: # noqa: BLE001 — retry then raise
  78. last_err = exc
  79. logger.warning(
  80. "cn_holiday batch fetch failed attempt=%s/%s: %s",
  81. attempt + 1,
  82. retries + 1,
  83. exc,
  84. )
  85. if attempt < retries:
  86. time.sleep(0.5 * (attempt + 1))
  87. assert last_err is not None
  88. raise last_err
  89. def _days_in_year(year: int) -> int:
  90. return 366 if calendar.isleap(year) else 365
  91. def is_year_calendar_complete(session: Any, year: int) -> bool:
  92. """True when the year already has one row per calendar day."""
  93. start = date(year, 1, 1)
  94. end = date(year, 12, 31)
  95. cnt = (
  96. session.query(SysCnHolidayDay)
  97. .filter(SysCnHolidayDay.day_date >= start, SysCnHolidayDay.day_date <= end)
  98. .count()
  99. )
  100. return int(cnt) >= _days_in_year(year)
  101. def sync_cn_holidays(db_session_factory: Callable[[], Any]) -> int:
  102. """Fetch current + next year month-by-month; upsert by day_date. Return rows written.
  103. Skip any year that already has a full calendar (row count >= days in that year).
  104. """
  105. with _sync_lock:
  106. written = 0
  107. session = db_session_factory()
  108. try:
  109. now = datetime.now()
  110. for year in _years_to_sync():
  111. if is_year_calendar_complete(session, year):
  112. logger.info("cn_holiday skip year=%s (calendar already complete)", year)
  113. continue
  114. for y, month in _months_for_year(year):
  115. date_strs = iter_month_dates(y, month)
  116. payload = _fetch_batch(date_strs)
  117. holiday_map = payload.get("holiday") or {}
  118. type_map = payload.get("type") or {}
  119. for ds in date_strs:
  120. holiday = holiday_map.get(ds)
  121. type_info = type_map.get(ds)
  122. mapped = map_ailcc_day_row(ds, holiday, type_info)
  123. existing = (
  124. session.query(SysCnHolidayDay)
  125. .filter(SysCnHolidayDay.day_date == mapped["day_date"])
  126. .first()
  127. )
  128. if existing is None:
  129. row = SysCnHolidayDay(
  130. id=uuid.uuid4().hex,
  131. day_date=mapped["day_date"],
  132. day_type=mapped["day_type"],
  133. day_name=mapped["day_name"],
  134. week=mapped["week"],
  135. is_rest_day=mapped["is_rest_day"],
  136. raw_json=mapped["raw_json"],
  137. sync_time=now,
  138. create_time=now,
  139. update_time=now,
  140. )
  141. session.add(row)
  142. else:
  143. existing.day_type = mapped["day_type"]
  144. existing.day_name = mapped["day_name"]
  145. existing.week = mapped["week"]
  146. existing.is_rest_day = mapped["is_rest_day"]
  147. existing.raw_json = mapped["raw_json"]
  148. existing.sync_time = now
  149. existing.update_time = now
  150. written += 1
  151. session.commit()
  152. logger.info("cn_holiday sync ok rows=%s", written)
  153. return written
  154. except Exception:
  155. session.rollback()
  156. logger.exception("cn_holiday sync failed")
  157. raise
  158. finally:
  159. session.close()
  160. def seconds_until_next_local_midnight(now: Optional[datetime] = None) -> float:
  161. now = now or datetime.now()
  162. # Always wait until the next calendar midnight (if already 00:00:00 → +1 day)
  163. nxt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
  164. return max(0.0, (nxt - now).total_seconds())
  165. def _loop_body(db_session_factory: Callable[[], Any], stop_event: threading.Event) -> None:
  166. try:
  167. sync_cn_holidays(db_session_factory)
  168. except Exception:
  169. logger.exception("cn_holiday initial sync failed; will retry at next midnight")
  170. while not stop_event.is_set():
  171. wait_sec = seconds_until_next_local_midnight()
  172. # Wait in chunks so stop responds quickly
  173. end_at = time.time() + wait_sec
  174. while not stop_event.is_set():
  175. remaining = end_at - time.time()
  176. if remaining <= 0:
  177. break
  178. stop_event.wait(timeout=min(remaining, 1.0))
  179. if stop_event.is_set():
  180. break
  181. try:
  182. sync_cn_holidays(db_session_factory)
  183. except Exception:
  184. logger.exception("cn_holiday midnight sync failed; keep old cache")
  185. def start_cn_holiday_sync_loop(db_session_factory: Callable[[], Any]) -> None:
  186. """Start daemon thread if not running: sync immediately, then each local midnight."""
  187. global _stop_event, _thread
  188. with _loop_lock:
  189. if _thread is not None and _thread.is_alive():
  190. return
  191. _stop_event = threading.Event()
  192. t = threading.Thread(
  193. target=_loop_body,
  194. args=(db_session_factory, _stop_event),
  195. name="cn-holiday-sync",
  196. daemon=True,
  197. )
  198. _thread = t
  199. t.start()
  200. def stop_cn_holiday_sync_loop(timeout: float = 2.0) -> None:
  201. """Set stop event and join the sync thread."""
  202. global _stop_event, _thread
  203. with _loop_lock:
  204. ev = _stop_event
  205. t = _thread
  206. if ev is not None:
  207. ev.set()
  208. if t is not None and t.is_alive():
  209. t.join(timeout=timeout)
  210. _thread = None
  211. _stop_event = None