From 7dcb22f573b48eed6feea84591612c94562c3087 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 14:17:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20apisbo.com=20?= =?UTF-8?q?=E4=B8=AD=E5=9B=BD=E8=8A=82=E5=81=87=E6=97=A5=20API=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=8E=B7=E5=8F=96+=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 holidays.py 服务:按年获取节假日数据,缓存到 system_config - 区分 holiday(工作日假日)和 workday(调休上班日)两种类型 - is_working_day() 支持调休判断:周末在 workdays 集合中视为工作日 - 启动时自动从 API 刷新当年+明年数据,失败则使用缓存 - 系统设置新增「从 API 刷新」按钮,支局长可手动触发 - 支持 2010-2026 年数据,API 免费免鉴权 Co-Authored-By: Claude --- backend/app/api/system_config.py | 14 +++ backend/app/main.py | 6 ++ backend/app/services/dashboard.py | 17 +-- backend/app/services/holidays.py | 136 ++++++++++++++++++++++++ backend/app/services/scheduler.py | 17 +-- backend/app/utils/timezone.py | 23 ++-- frontend/src/views/desktop/Settings.vue | 19 +++- 7 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 backend/app/services/holidays.py diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py index 860965d..e7d6749 100644 --- a/backend/app/api/system_config.py +++ b/backend/app/api/system_config.py @@ -6,6 +6,7 @@ from sqlalchemy import select from app.database import get_db from app.middleware.auth import get_current_user, require_director from app.models.system_config import SystemConfig +from app.services.holidays import refresh_holidays from app.services.scheduler_manager import reschedule_daily_check router = APIRouter(prefix="/system-config", tags=["SystemConfig"]) @@ -95,3 +96,16 @@ async def update_config( await _set_config(db, key, value) return {"key": key, "value": value} + + +@router.post("/refresh-holidays") +async def refresh_holidays_endpoint( + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Fetch the latest Chinese holiday data from apisbo.com and cache it.""" + try: + result = await refresh_holidays(db) + return {"status": "ok", **result} + except Exception as e: + raise HTTPException(status_code=500, detail=f"刷新节假日数据失败: {str(e)}") diff --git a/backend/app/main.py b/backend/app/main.py index 606c97e..7d11e4b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from app.api import router as api_router from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves from app.models.system_config import SystemConfig +from app.services.holidays import refresh_holidays from app.services.scheduler_manager import start_scheduler, shutdown_scheduler @@ -84,6 +85,11 @@ async def lifespan(app: FastAPI): notification_hour, notification_minute = int(parts[0]), int(parts[1]) except (ValueError, IndexError): pass + # Auto-refresh Chinese holiday data from API on startup + try: + await refresh_holidays(db) + except Exception: + pass # Use cached data if API unavailable # Start daily reporting scheduler start_scheduler(notification_hour, notification_minute) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 8c7feb0..821c9e9 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -11,7 +11,7 @@ from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote from app.models.leave import Leave -from app.models.system_config import SystemConfig +from app.services.holidays import load_holiday_sets from app.utils.timezone import today_cst, is_working_day @@ -79,21 +79,12 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None = ) visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} - # Load holiday config - holiday_row = await db.get(SystemConfig, "holidays") - holidays: set[date] = set() - if holiday_row and holiday_row.value: - for s in holiday_row.value.split(","): - s = s.strip() - if s: - try: - holidays.add(date.fromisoformat(s)) - except ValueError: - pass + # Load holiday data (auto-refreshes from API if not cached) + holidays, workdays = await load_holiday_sets(db) # Query leave records for today — build a set of managers on leave today = today_cst() - is_rest = not is_working_day(today, holidays) + is_rest = not is_working_day(today, holidays, workdays) leaves_result = await db.execute( select(Leave).where(and_( diff --git a/backend/app/services/holidays.py b/backend/app/services/holidays.py new file mode 100644 index 0000000..87e627f --- /dev/null +++ b/backend/app/services/holidays.py @@ -0,0 +1,136 @@ +"""Chinese holiday data service — fetches from apisbo.com and caches to system_config.""" + +import json +import httpx +from datetime import date +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.system_config import SystemConfig + + +HOLIDAY_API = "https://api.apisbo.com/holidays/year" + + +async def _get_config(db: AsyncSession, key: str) -> str | None: + row = await db.get(SystemConfig, key) + return row.value if row else None + + +async def _set_config(db: AsyncSession, key: str, value: str): + row = await db.get(SystemConfig, key) + if row: + row.value = value + else: + db.add(SystemConfig(key=key, value=value)) + await db.commit() + + +async def refresh_holidays(db: AsyncSession, year: int | None = None) -> dict: + """Fetch Chinese holidays for a year from apisbo.com and cache to DB. + + Caches two config keys: + - holidays: comma-separated rest-day dates (holidays + weekends already handled by weekday check) + We only store holiday dates here, since weekends are auto-detected. + - workdays: comma-separated makeup workday dates (调休, when Sat/Sun becomes a workday) + + Returns dict with counts. + """ + year = year or date.today().year + + # Fetch current year + next year on first load + years_to_fetch = {year} + existing = await _get_config(db, f"holidays_{year}") + if not existing: + years_to_fetch.add(year + 1) + + all_holidays: list[str] = [] + all_workdays: list[str] = [] + + for y in years_to_fetch: + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(f"{HOLIDAY_API}/{y}") + resp.raise_for_status() + result = resp.json() + + if result.get("code") != 0: + continue + + holidays = [] + workdays = [] + for item in result.get("data", []): + d = item["date"] + if item["type"] == "holiday" and date.fromisoformat(d).weekday() < 5: + # Only cache weekday-holidays (weekend holidays are already skipped) + holidays.append(d) + elif item["type"] == "workday": + # Makeup workday — Saturday/Sunday that becomes a workday + workdays.append(d) + + # Store per-year for reference, and accumulate for runtime use + await _set_config(db, f"holidays_{y}", ",".join(holidays)) + await _set_config(db, f"workdays_{y}", ",".join(workdays)) + all_holidays.extend(holidays) + all_workdays.extend(workdays) + + except Exception: + # API unavailable — fall back to existing cached data + cached_h = await _get_config(db, f"holidays_{y}") + if cached_h: + all_holidays.extend([d for d in cached_h.split(",") if d.strip()]) + cached_w = await _get_config(db, f"workdays_{y}") + if cached_w: + all_workdays.extend([d for d in cached_w.split(",") if d.strip()]) + + # Write runtime config keys (used by is_working_day) + if all_holidays or all_workdays: + await _set_config(db, "holidays", ",".join(sorted(all_holidays))) + await _set_config(db, "workdays", ",".join(sorted(all_workdays))) + + return { + "holidays": len(all_holidays), + "workdays": len(all_workdays), + "years": sorted(years_to_fetch), + } + + +async def load_holiday_sets(db: AsyncSession) -> tuple[set[date], set[date]]: + """Load holiday and workday date sets from cached config. + + Returns (holidays_set, workdays_set). + - holidays_set: dates that are rest days (weekday holidays) + - workdays_set: dates that are workdays despite being weekends (调休) + """ + holidays: set[date] = set() + workdays: set[date] = set() + + # Try runtime config first + h_val = await _get_config(db, "holidays") + if h_val: + for s in h_val.split(","): + s = s.strip() + if s: + try: + holidays.add(date.fromisoformat(s)) + except ValueError: + pass + + w_val = await _get_config(db, "workdays") + if w_val: + for s in w_val.split(","): + s = s.strip() + if s: + try: + workdays.add(date.fromisoformat(s)) + except ValueError: + pass + + # If no data cached yet, try to refresh + if not holidays and not workdays: + try: + await refresh_holidays(db) + # Reload after refresh + return await load_holiday_sets(db) + except Exception: + pass + + return holidays, workdays diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 7c202dd..33e192c 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -7,7 +7,7 @@ from app.models.user import User from app.models.leave import Leave from app.models.work_plan import WorkPlan from app.models.customer import Customer -from app.models.system_config import SystemConfig +from app.services.holidays import load_holiday_sets from app.services.wecom import wecom_client from app.utils.timezone import today_cst, is_working_day @@ -16,19 +16,10 @@ async def check_daily_reporting(db: AsyncSession) -> dict: """Check today's reporting progress and send wecom reminders to managers who haven't reported.""" today = today_cst() - # Load holiday config - holiday_row = await db.get(SystemConfig, "holidays") - holidays: set[date] = set() - if holiday_row and holiday_row.value: - for s in holiday_row.value.split(","): - s = s.strip() - if s: - try: - holidays.add(date.fromisoformat(s)) - except ValueError: - pass + # Load holiday data (auto-refreshes from API if not cached) + holidays, workdays = await load_holiday_sets(db) - if not is_working_day(today, holidays): + if not is_working_day(today, holidays, workdays): return {"status": "rest_day", "date": str(today)} # Get all users who need to report diff --git a/backend/app/utils/timezone.py b/backend/app/utils/timezone.py index 7afc14a..739a287 100644 --- a/backend/app/utils/timezone.py +++ b/backend/app/utils/timezone.py @@ -13,19 +13,30 @@ def parse_date(s: str) -> date: return date.fromisoformat(s[:10]) -def is_working_day(d: date | None = None, holidays: set[date] | None = None) -> bool: +def is_working_day(d: date | None = None, holidays: set[date] | None = None, workdays: set[date] | None = None) -> bool: """Check if a date is a Chinese working day. Returns False for: - - Saturdays and Sundays (weekday >= 5) - - Dates in the holidays set (configurable via system_config) + - Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休) + - Dates in the holidays set (weekday holidays like 春节/国庆) - Note: makeup workdays (调休, where Saturday/Sunday becomes a workday) - are not currently handled. If needed, add a 'workdays' config set. + Returns True for: + - Monday-Friday, unless in holidays set + - Saturday/Sunday that are in the workdays set (调休 makeup days) """ d = d or today_cst() - if d.weekday() >= 5: # Saturday or Sunday + is_weekend = d.weekday() >= 5 + + # 调休: weekend that becomes a workday + if is_weekend and workdays and d in workdays: + return True + + # Normal weekend + if is_weekend: return False + + # Weekday holiday if holidays and d in holidays: return False + return True diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 2d145d9..db8d4be 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -17,6 +17,7 @@ const notificationTime = ref('17:30') const notificationTimeLoading = ref(false) const holidays = ref('') const holidaysLoading = ref(false) +const holidaysRefreshing = ref(false) const importLoading = ref(false) const importFile = ref(null) @@ -90,6 +91,19 @@ async function handleSaveHolidays() { } finally { holidaysLoading.value = false } } +async function handleRefreshHolidays() { + holidaysRefreshing.value = true + try { + const res = await api.post('/system-config/refresh-holidays') + ElMessage.success(`节假日数据已刷新:${res.data.holidays} 个假日,${res.data.workdays} 个调休`) + // Reload the config display + const cfg = await api.get('/system-config') + if (cfg.data?.holidays) holidays.value = cfg.data.holidays + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '刷新失败') + } finally { holidaysRefreshing.value = false } +} + function handleImportFile(e: Event) { const target = e.target as HTMLInputElement if (target.files?.[0]) importFile.value = target.files[0] @@ -246,7 +260,10 @@ async function handleImportPreview() { /> 保存 -

配置后,仪表盘和催办提醒在这些日期将跳过填报检查(周末自动跳过无需配置)

+

+ 数据来源:apisbo.com 中国节假日 API · 周末自动跳过无需配置 + 从 API 刷新 +