Files
InboundVerify/inbound_verify/cli/server.py

436 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# server.py
#
# 服务模式入口FastAPI主线程uvicorn/asyncio+ Playwright worker独立线程
# 客户端经 HTTP 触发任务、查状态、下载数据worker 串行执行任务并跑心跳。
#
# 线程模型(关键):
# - 主线程uvicorn + FastAPI。路由【绝不】访问 Playwright 对象,只经
# task_queue投递任务+ state_store查状态/任务)+ 文件系统(下载数据)。
# - worker 线程runtime.launch_and_preparesync_playwright 在此)+ 任务循环,
# 独占所有 page 操作;与主线程仅经 Queue + SQLite 通信。
# 违反"路由不碰 Playwright"会崩sync 对象跨线程访问)。
#
# 运行python -m inbound_verify.cli.server (或 inbound-verify-server默认监听 0.0.0.0:8000
import os
import queue
import threading
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Dict, Optional
import uvicorn
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from inbound_verify.paths import OUTPUT_DIR
from inbound_verify import state_store
from inbound_verify.runtime import (
HEARTBEAT_INTERVAL,
TASK_HANDLERS,
dispatch_task,
launch_and_prepare,
run_heartbeat,
)
from inbound_verify import db_compare
# 全部站点;百世固定下载当天,不可配置偏移
ALL_SITES = ["顺心", "百世", "中通", "韵达", "安能"]
CONFIGURABLE_SITES = {"顺心", "中通", "韵达", "安能"}
# 任务队列:元素 (task_id, task_spec)。主线程投递worker 消费。
task_queue: "queue.Queue" = queue.Queue()
# worker 运行状态主线程只读worker 写)
worker_state = {
"ctx": None,
"stop": False,
"thread": None,
"ready": False, # launch_and_prepare 完成(各站就绪,可接任务)
"error": None, # worker 启动失败原因
}
# 每日定时下载调度器进程内job 只往 task_queue 投任务,不碰 Playwright
scheduler = BackgroundScheduler(daemon=True)
def _worker_loop():
"""worker 线程:启动 Playwright + 等就绪 + 任务循环(执行任务 + 心跳)。"""
try:
ctx = launch_and_prepare(foreground=False)
worker_state["ctx"] = ctx
worker_state["ready"] = True
# 【P1-2 重启自愈】worker 就绪后清理上轮遗留的 pending/running 僵尸任务
cleaned = state_store.fail_stale_tasks()
if cleaned:
print(
f">> [worker] 自愈:清理 {cleaned} 条遗留任务pending/running → failed"
)
print(">> [worker] 各站就绪,开始接收任务 ...")
except Exception as e:
worker_state["error"] = str(e)
print(f"❌ [worker] 启动失败: {e}")
return
last_heartbeat = 0.0
while not worker_state["stop"]:
try:
task_id, task_spec = task_queue.get(timeout=1)
except queue.Empty:
# 空闲时跑心跳
if time.monotonic() - last_heartbeat >= HEARTBEAT_INTERVAL:
run_heartbeat(ctx)
last_heartbeat = time.monotonic()
continue
state_store.update_task(task_id, state_store.TASK_RUNNING)
print(f">> [worker] 执行任务 #{task_id}: {task_spec}")
status, error = dispatch_task(ctx, task_spec)
state_store.update_task(task_id, status, error)
print(f">> [worker] 任务 #{task_id} 完成: {status} {error or ''}")
try:
ctx.stop()
except Exception:
pass
worker_state["ready"] = False
print(">> [worker] 已退出。")
def _in_active_window(active_start, active_end):
"""当前本地时间是否落在激活时段内(避免半夜空跑)。
- start/end 任一为空 → 不限时段24h 活跃)
- start == end非空→ 视为全天活跃
- start < end → 半开区间 [start, end)
- start > end → 跨午夜(如 22:00-06:00now >= start 或 now < end
"""
if not active_start or not active_end:
return True
now = datetime.now().strftime("%H:%M")
if active_start == active_end:
return True
if active_start < active_end:
return active_start <= now < active_end
return now >= active_start or now < active_end
def _enqueue_fetch(site, kind):
"""周期 job 回调worker 就绪 + 在激活时段内 + 该(site,kind)无未完成任务时,投递一次抓取任务。
跑在 APScheduler 线程池线程——绝不碰 Playwright只经 SQLite + task_queue 通信。"""
if not worker_state["ready"]:
return # worker 未就绪:与 POST /tasks 的 409 同义,下个周期补抓
cfg = state_store.get_fetch_schedule(site, kind)
if not cfg or not cfg["enabled"]:
return # 已禁用job 本应已注销,双重保险)
if not _in_active_window(cfg["active_start"], cfg["active_end"]):
return # 不在激活时段,跳过本次 fire
try:
target_date = state_store.resolve_target_date(site, kind)
tid = state_store.create_task_if_idle(
site, kind, trigger="auto", target_date=target_date
)
if tid is None:
return # 上一次同类任务还没跑完,跳过避免堆积
task_queue.put((tid, {"site": site, "kind": kind}))
print(f">> [周期] 投递 {site}/{kind} 任务 #{tid}")
except Exception as e:
print(f">> [周期] 投递 {site}/{kind} 失败: {e}")
def _reschedule_fetch(site, kind):
"""按持久化配置(重新)注册或取消该 (site,kind) 的周期抓取 jobIntervalTrigger"""
job_id = f"{site}_{kind}"
try:
scheduler.remove_job(job_id)
except Exception:
pass
cfg = state_store.get_fetch_schedule(site, kind)
if not cfg or not cfg["enabled"] or cfg["interval_minutes"] < 1:
return # 未启用或频率非法:不注册(等同取消)
try:
scheduler.add_job(
_enqueue_fetch,
IntervalTrigger(minutes=cfg["interval_minutes"]),
args=[site, kind],
id=job_id,
replace_existing=True,
)
print(
f">> [周期] 已注册 {site}/{kind}:每 {cfg['interval_minutes']} 分钟"
f"(激活 {cfg['active_start'] or '不限'}~{cfg['active_end'] or '不限'}"
)
except Exception as e:
print(f">> [周期] 注册 {site}/{kind} 失败: {e}")
@asynccontextmanager
async def lifespan(_app):
"""服务启停:起 worker 线程 / 通知 worker 停。"""
state_store.init_db() # 先建表/迁移状态库,确保早于 worker 就绪的 /api/status 可用
for site in ALL_SITES: # 按持久化配置注册各站各 kind 的周期抓取 job
for kind in state_store.allowed_kinds(site):
_reschedule_fetch(site, kind)
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)
app = FastAPI(title="InboundVerify 服务端", lifespan=lifespan)
class TaskRequest(BaseModel):
site: str
kind: str
force: bool = (
False # 强制重下:忽略已落库去重,重新提交所有班次/交接单的导出任务(默认关)
)
date: Optional[str] = None # YYYY-MM-DD指定则下载该日数据否则走站点 offset
@app.post("/tasks")
def create_task(req: TaskRequest):
"""提交任务 {site, kind, force, date?} → 入队,返回 task_id。"""
# 【P0】后端未就绪时直接拒绝避免任务在 worker 启动前入队卡死
if not worker_state["ready"]:
raise HTTPException(
status_code=409, detail="后端尚未就绪,请等待各站点登录完成后再操作"
)
if (req.site, req.kind) not in TASK_HANDLERS:
raise HTTPException(status_code=400, detail=f"无效任务: {req.site}/{req.kind}")
# 指定日期合法性校验(仅在传了 date 时)
if req.date:
try:
target_date = datetime.strptime(req.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail=f"date 格式非法,需 YYYY-MM-DD: {req.date}"
)
today = datetime.now().date()
if target_date > today:
raise HTTPException(
status_code=400, detail=f"date 不可为未来日期: {req.date}"
)
if target_date < today - timedelta(days=31):
raise HTTPException(
status_code=400, detail=f"date 超出 31 天回溯上限: {req.date}"
)
if req.site == "百世":
raise HTTPException(
status_code=400, detail="百世固定下载当天,不支持指定日期"
)
task_id = state_store.create_task(
req.site,
req.kind,
trigger="manual",
target_date=state_store.resolve_target_date(req.site, req.kind, req.date),
force=req.force,
)
spec = {"site": req.site, "kind": req.kind, "force": req.force}
if req.date:
spec["date"] = req.date
task_queue.put((task_id, spec))
return {"task_id": task_id}
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
t = state_store.get_task(task_id)
if not t:
raise HTTPException(status_code=404, detail="任务不存在")
return t
@app.get("/tasks")
def list_tasks(limit: int = 20):
return state_store.list_tasks(limit)
# ── DB 比对(基于 PostgreSQL不依赖 Excel 文件)──
class CompareRequest(BaseModel):
site: str
date: str # YYYY-MM-DD
@app.post("/compare")
def run_compare(req: CompareRequest):
"""DB 差缺比对:以实到扫描日期为锚点,反推交接批次,展开全量比对。
返回统计指标 + 差缺明细。
"""
# 合法性校验
if req.site not in db_compare.SITE_COMPARE_CONFIG:
raise HTTPException(
status_code=400,
detail=f"不支持的站点: {req.site}(支持: {list(db_compare.SITE_COMPARE_CONFIG.keys())}",
)
try:
target_date = datetime.strptime(req.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail=f"date 格式非法,需 YYYY-MM-DD: {req.date}"
)
today = datetime.now().date()
if target_date > today:
raise HTTPException(status_code=400, detail=f"date 不可为未来日期: {req.date}")
result = db_compare.compare_site_date(req.site, req.date)
if result is None:
raise HTTPException(
status_code=404,
detail=f"{req.site} {req.date}: 当天无实到数据,无法比对",
)
return {
"site": result.site,
"date": result.date,
"batches": result.batches,
"stats": {
"waybill_count": result.stats.waybill_count,
"sf_wb_count": result.stats.sf_wb_count,
"expected_pieces": result.stats.expected_pieces,
"arrived_pieces": result.stats.arrived_pieces,
"undelivered_pieces": result.stats.undelivered_pieces,
"undelivered_wb": result.stats.undelivered_wb,
"full_miss": result.stats.full_miss,
"part_miss": result.stats.part_miss,
"sf_undelivered": result.stats.sf_undelivered,
},
"rows": [
{
"handover_no": r.handover_no,
"waybill_no": r.waybill_no,
"total_pieces": r.total_pieces,
"arrived_pieces": r.arrived_pieces,
"arrived_list": r.arrived_list,
"is_sf": r.is_sf,
}
for r in result.rows
],
}
@app.get("/status")
def get_status():
"""各站登录态 + 数据态 + 入库态(前端状态盘用),另含 worker 就绪状态。"""
return {
"worker_ready": worker_state["ready"],
"worker_error": worker_state["error"],
"sites": state_store.get_all_status(),
"ingest": state_store.get_all_ingest_state(),
}
class FetchScheduleSpec(BaseModel):
enabled: bool
active_start: str = ""
active_end: str = ""
interval_minutes: int
class ConfigRequest(BaseModel):
expected_offset: Optional[int] = None
actual_offset: Optional[int] = None
# DEPRECATED旧"每日定点"字段,保留一个发布周期兼容旧前端请求(收到即忽略)
schedule_enabled: Optional[bool] = None
schedule_time: Optional[str] = None
settings: Optional[Dict[str, str]] = None
fetch_schedules: Optional[Dict[str, FetchScheduleSpec]] = None
def _default_cfg():
return {
"expected_offset": 0,
"actual_offset": 0,
"fetch_schedules": {},
}
@app.get("/config")
def get_config():
"""各站配置:应到/实到偏移 + 每日定时(百世偏移恒 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: ConfigRequest):
if site not in ALL_SITES:
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
# 应到/实到偏移(百世锁定当天)
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)
# 周期抓取调度(应到/实到各自独立;百世只允许 undelivered
if req.fetch_schedules:
allowed = state_store.allowed_kinds(site)
for kind, spec in req.fetch_schedules.items():
if kind not in allowed:
raise HTTPException(
status_code=400,
detail=f"站点 {site} 不支持抓取类型 {kind}(允许: {list(allowed)}",
)
interval = max(1, min(1440, int(spec.interval_minutes)))
state_store.set_fetch_schedule(
site,
kind,
spec.enabled,
spec.active_start,
spec.active_end,
interval,
)
_reschedule_fetch(site, kind)
# 站点专属配置(密码/账号/路径…)
if req.settings:
for k, v in req.settings.items():
state_store.set_setting(site, k, v)
cfg = state_store.get_all_config().get(site, _default_cfg())
cfg["settings"] = state_store.get_site_settings(site)
return cfg
@app.get("/config/{site}/settings")
def get_site_settings_api(site: str):
"""单站专属配置(密码/账号/路径…;不进 5s 轮询)。"""
if site not in ALL_SITES:
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
return state_store.get_site_settings(site)
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)
def main():
"""服务模式入口。传字符串导入路径(规范写法;不开 reload/workers 时进程内 import行为等价"""
uvicorn.run("inbound_verify.cli.server:app", host="0.0.0.0", port=8000)
if __name__ == "__main__":
main()