偏移量功能(前端/API 可配,服务端持久化、多用户共享): - state_store:新增 site_config 表 + get_offset / set_offset(0..30 钳制) / get_all_offsets - server:新增 GET/PUT /config(百世锁定当天,不可配置) - 顺心/中通/韵达/安能(各 expected+actual):日期逻辑由 query_days 范围改为读 offset 算单日 target=今天-offset,起止同日;百世仍走当日 - config.example.yaml:query_days 标记为已废弃 修复启动时显示上一会话陈旧登录态: - state_store:新增 reset_login_states(登录态会话级,启动重置为 unknown;数据态会话无关、保留) - runtime.launch_and_prepare:启动时对各站重置登录态,心跳就绪后重新探测写真实值 Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
# server.py
|
||
#
|
||
# 服务模式入口:FastAPI(主线程,uvicorn/asyncio)+ Playwright worker(独立线程)。
|
||
# 客户端经 HTTP 触发任务、查状态、下载数据;worker 串行执行任务并跑心跳。
|
||
#
|
||
# 线程模型(关键):
|
||
# - 主线程:uvicorn + FastAPI。路由【绝不】访问 Playwright 对象,只经
|
||
# task_queue(投递任务)+ state_store(查状态/任务)+ 文件系统(下载数据)。
|
||
# - worker 线程:runtime.launch_and_prepare(sync_playwright 在此)+ 任务循环,
|
||
# 独占所有 page 操作;与主线程仅经 Queue + SQLite 通信。
|
||
# 违反"路由不碰 Playwright"会崩(sync 对象跨线程访问)。
|
||
#
|
||
# 运行:python server.py (默认监听 0.0.0.0:8000)
|
||
|
||
import os
|
||
import queue
|
||
import threading
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
|
||
import uvicorn
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel
|
||
|
||
from paths import DOWNLOAD_DIR
|
||
import state_store
|
||
from runtime import (
|
||
HEARTBEAT_INTERVAL,
|
||
TASK_HANDLERS,
|
||
dispatch_task,
|
||
launch_and_prepare,
|
||
run_heartbeat,
|
||
)
|
||
|
||
# 全部站点;百世固定下载当天,不可配置偏移
|
||
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 启动失败原因
|
||
}
|
||
|
||
|
||
def _worker_loop():
|
||
"""worker 线程:启动 Playwright + 等就绪 + 任务循环(执行任务 + 心跳)。"""
|
||
try:
|
||
ctx = launch_and_prepare()
|
||
worker_state["ctx"] = ctx
|
||
worker_state["ready"] = True
|
||
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] 已退出。")
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app):
|
||
"""服务启停:起 worker 线程 / 通知 worker 停。"""
|
||
t = threading.Thread(target=_worker_loop, daemon=True)
|
||
worker_state["thread"] = t
|
||
t.start()
|
||
yield
|
||
worker_state["stop"] = True
|
||
t.join(timeout=10)
|
||
|
||
|
||
app = FastAPI(title="InboundVerify 服务端", lifespan=lifespan)
|
||
|
||
|
||
class TaskRequest(BaseModel):
|
||
site: str
|
||
kind: str
|
||
|
||
|
||
@app.post("/tasks")
|
||
def create_task(req: TaskRequest):
|
||
"""提交任务 {site, kind} → 入队,返回 task_id。"""
|
||
if (req.site, req.kind) not in TASK_HANDLERS:
|
||
raise HTTPException(status_code=400, detail=f"无效任务: {req.site}/{req.kind}")
|
||
task_id = state_store.create_task(req.site, req.kind)
|
||
task_queue.put((task_id, {"site": req.site, "kind": req.kind}))
|
||
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)
|
||
|
||
|
||
@app.get("/status")
|
||
def get_status():
|
||
"""各站登录态 + 数据态(前端状态盘用),另含 worker 就绪状态。"""
|
||
return {
|
||
"worker_ready": worker_state["ready"],
|
||
"worker_error": worker_state["error"],
|
||
"sites": state_store.get_all_status(),
|
||
}
|
||
|
||
|
||
class OffsetRequest(BaseModel):
|
||
date_offset: int
|
||
|
||
|
||
@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}
|
||
|
||
|
||
@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:
|
||
raise HTTPException(status_code=400, detail=f"未知站点: {site}")
|
||
stored = state_store.set_offset(site, req.date_offset)
|
||
return {"site": site, "date_offset": stored}
|
||
|
||
|
||
@app.get("/data/{filename}")
|
||
def download_data(filename: str):
|
||
"""下载 downloads/ 下的数据文件(防路径穿越)。"""
|
||
if not filename or "/" in filename or "\\" in filename or ".." in filename:
|
||
raise HTTPException(status_code=400, detail="非法文件名")
|
||
path = os.path.join(DOWNLOAD_DIR, filename)
|
||
# 双重校验:解析后绝对路径仍在 DOWNLOAD_DIR 内
|
||
if not os.path.abspath(path).startswith(os.path.abspath(DOWNLOAD_DIR) + os.sep):
|
||
raise HTTPException(status_code=400, detail="非法路径")
|
||
if not os.path.isfile(path):
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
return FileResponse(path, filename=filename)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|