定时下载(APScheduler) + 跑比对纯离线 + 偏移拆 expected/actual + 报告下载

定时下载:
- state_store:site_config 加 schedule_enabled/schedule_time(旧库迁移)+ set_schedule/get_all_config
- server:APScheduler BackgroundScheduler,启动按配置注册各站 cron job、改配置重排;job 投 undelivered 任务到队列(错过跳过);/config 扩展 schedule
- runtime:("__compare__","compare") 改纯离线 expected_undelivered.main()(不下载)
- requirements:加 apscheduler

偏移拆分 + 报告下载:
- state_store:date_offset 拆 expected_offset/actual_offset(迁移)+ get_offset(site,kind)/set_offset(site,kind,n)
- 4 站 actual impl 改读 actual 偏移(expected 走默认 kind)
- server:/config 用 expected_offset/actual_offset;新增 GET /report(服务 output/应到未到数据.xlsx)
- paths:加 OUTPUT_DIR

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-18 08:11:18 +08:00
parent 82c80fc859
commit 96e7018146
9 changed files with 177 additions and 51 deletions

115
server.py
View File

@@ -17,13 +17,16 @@ import queue
import threading
import time
from contextlib import asynccontextmanager
from typing import Optional
import uvicorn
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from paths import DOWNLOAD_DIR
from paths import DOWNLOAD_DIR, OUTPUT_DIR
import state_store
from runtime import (
HEARTBEAT_INTERVAL,
@@ -48,6 +51,9 @@ worker_state = {
"error": None, # worker 启动失败原因
}
# 每日定时下载调度器进程内job 只往 task_queue 投任务,不碰 Playwright
scheduler = BackgroundScheduler(daemon=True)
def _worker_loop():
"""worker 线程:启动 Playwright + 等就绪 + 任务循环(执行任务 + 心跳)。"""
@@ -86,14 +92,54 @@ def _worker_loop():
print(">> [worker] 已退出。")
def _enqueue_undelivered(site):
"""定时 job把该站 undelivered 任务投到队列worker 串行处理;本线程不碰 Playwright"""
try:
tid = state_store.create_task(site, "undelivered")
task_queue.put((tid, {"site": site, "kind": "undelivered"}))
print(f">> [定时] 投递 {site}/undelivered 任务 #{tid}")
except Exception as e:
print(f">> [定时] 投递 {site} 失败: {e}")
def _reschedule_site(site):
"""按持久化配置(重新)注册或取消该站的每日定时 job。"""
job_id = f"site_{site}"
try:
scheduler.remove_job(job_id)
except Exception:
pass
cfg = state_store.get_all_config().get(site)
if not cfg or not cfg.get("schedule_enabled") or not cfg.get("schedule_time"):
return
try:
hh, mm = cfg["schedule_time"].split(":")
scheduler.add_job(
_enqueue_undelivered,
CronTrigger(hour=int(hh), minute=int(mm)),
args=[site],
id=job_id,
replace_existing=True,
)
print(f">> [定时] 已注册 {site} 每日 {cfg['schedule_time']} 下载")
except Exception as e:
print(f">> [定时] 注册 {site} 失败: {e}")
@asynccontextmanager
async def lifespan(_app):
"""服务启停:起 worker 线程 / 通知 worker 停。"""
state_store.init_db() # 先建表/迁移状态库,确保早于 worker 就绪的 /api/status 可用
for site in ALL_SITES: # 按持久化配置注册各站每日定时 job
_reschedule_site(site)
scheduler.start()
print(">> [定时] 调度器已启动")
t = threading.Thread(target=_worker_loop, daemon=True)
worker_state["thread"] = t
t.start()
yield
scheduler.shutdown(wait=False)
print(">> [定时] 调度器已停止")
worker_state["stop"] = True
t.join(timeout=10)
@@ -139,25 +185,70 @@ def get_status():
}
class OffsetRequest(BaseModel):
date_offset: int
class ConfigRequest(BaseModel):
expected_offset: Optional[int] = None
actual_offset: Optional[int] = None
schedule_enabled: Optional[bool] = None
schedule_time: Optional[str] = None
def _default_cfg():
return {
"expected_offset": 0,
"actual_offset": 0,
"schedule_enabled": False,
"schedule_time": "",
}
@app.get("/config")
def get_config():
"""各站下载日期偏移0=今天1=昨天…);百世恒 0。"""
offsets = state_store.get_all_offsets()
return {site: offsets.get(site, 0) for site in ALL_SITES}
"""各站配置:应到/实到偏移 + 每日定时(百世偏移恒 0"""
cfg = state_store.get_all_config()
return {site: cfg.get(site, _default_cfg()) for site in ALL_SITES}
@app.put("/config/{site}")
def set_config(site: str, req: OffsetRequest):
if site == "百世":
raise HTTPException(status_code=400, detail="百世固定下载当天,不可配置偏移")
if site not in CONFIGURABLE_SITES:
def set_config(site: str, req: ConfigRequest):
if site not in ALL_SITES:
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
stored = state_store.set_offset(site, req.date_offset)
return {"site": site, "date_offset": stored}
cur = state_store.get_all_config().get(site, _default_cfg())
# 应到/实到偏移(百世锁定当天)
for kind, val in (
("expected", req.expected_offset),
("actual", req.actual_offset),
):
if val is not None:
if site == "百世":
raise HTTPException(
status_code=400, detail="百世固定下载当天,不可配置偏移"
)
state_store.set_offset(site, kind, val)
# 每日定时(所有站可配,含百世)
if req.schedule_enabled is not None or req.schedule_time is not None:
enabled = (
req.schedule_enabled
if req.schedule_enabled is not None
else cur["schedule_enabled"]
)
time_str = (
req.schedule_time if req.schedule_time is not None else cur["schedule_time"]
)
state_store.set_schedule(site, enabled, time_str)
_reschedule_site(site)
return state_store.get_all_config().get(site, _default_cfg())
REPORT_FILE = "应到未到数据.xlsx"
@app.get("/report")
def download_report():
"""下载 output/应到未到数据.xlsx跑比对生成未生成 404"""
path = os.path.join(OUTPUT_DIR, REPORT_FILE)
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="报告尚未生成,请先跑比对")
return FileResponse(path, filename=REPORT_FILE)
@app.get("/data/{filename}")