通过调度系统接入
This commit is contained in:
parent
f91a2f86e7
commit
de7c340342
5
api.py
5
api.py
|
|
@ -10,7 +10,8 @@ cron 的 docker exec 构建/出计划照旧,互不影响。局域网内部服
|
||||||
GET /plan/dates 可用日期列表
|
GET /plan/dates 可用日期列表
|
||||||
POST /plan/refresh?date=... 重新生成该日计划文件(data/plan/*.md)
|
POST /plan/refresh?date=... 重新生成该日计划文件(data/plan/*.md)
|
||||||
|
|
||||||
将来接 XXL-JOB / 事件回调,触发器打这层即可,不必进容器。
|
统一任务调度平台(XXL-JOB)触发入口挂在 /api/v1/xxl/*(见 xxl.py,2026-08-03):
|
||||||
|
盘前链(build → plan → push-pool)可由平台拉起并回调结案,.env 配 XXL_TRIGGER_KEY 才启用。
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
@ -18,8 +19,10 @@ from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import plan
|
import plan
|
||||||
|
from xxl import router as xxl_router
|
||||||
|
|
||||||
app = FastAPI(title="akg-factor-bridge · 每日选股计划", version="0.1")
|
app = FastAPI(title="akg-factor-bridge · 每日选股计划", version="0.1")
|
||||||
|
app.include_router(xxl_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,12 @@ ENABLE_TRACK_GATE = os.environ.get("ENABLE_TRACK_GATE", "0") == "1"
|
||||||
# 因此不进主榜、观察档、计划与升降档。研究口径想看全貌时置 0 关闭。
|
# 因此不进主榜、观察档、计划与升降档。研究口径想看全貌时置 0 关闭。
|
||||||
EXCLUDE_RISK_NAMES = os.environ.get("EXCLUDE_RISK_NAMES", "1") == "1"
|
EXCLUDE_RISK_NAMES = os.environ.get("EXCLUDE_RISK_NAMES", "1") == "1"
|
||||||
|
|
||||||
|
# --- 统一任务调度平台触发(08-03;键名与决策系统保持一致,便于平台侧统一配置)----
|
||||||
|
# 空串 = /api/v1/xxl/* 整组端点禁用(安全默认)。回调契约见 xxl.py 模块说明。
|
||||||
|
XXL_TRIGGER_KEY = os.environ.get("XXL_TRIGGER_KEY", "")
|
||||||
|
XXL_CALLBACK_READ_TIMEOUT = float(os.environ.get("XXL_CALLBACK_READ_TIMEOUT", "10"))
|
||||||
|
XXL_CALLBACK_MAX_RETRIES = int(os.environ.get("XXL_CALLBACK_MAX_RETRIES", "3"))
|
||||||
|
|
||||||
# --- 选股计划入池(08-03 定稿;规则与流程见 docs/选股计划入池_对接说明.md)------
|
# --- 选股计划入池(08-03 定稿;规则与流程见 docs/选股计划入池_对接说明.md)------
|
||||||
# 写 Mongo stock_groups 的独立分组,决策系统每晚扫描按分组并集覆盖 → 候选票自动
|
# 写 Mongo stock_groups 的独立分组,决策系统每晚扫描按分组并集覆盖 → 候选票自动
|
||||||
# 获得夜间推理。入池范围与 PMS 候选同口径:强传导主榜前 POOL_TOP 只 + 当前持仓。
|
# 获得夜间推理。入池范围与 PMS 候选同口径:强传导主榜前 POOL_TOP 只 + 当前持仓。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""平台触发入口(xxl.py)的单测——子进程与回调都打桩,不跑真链、不发真请求。
|
||||||
|
|
||||||
|
运行: docker compose exec -T akg-factor-bridge python test_xxl_trigger.py
|
||||||
|
全过输出 "ALL PASS (n cases)",任一失败退出码 1。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
import config
|
||||||
|
import xxl
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(name):
|
||||||
|
def deco(fn):
|
||||||
|
RESULTS.append((name, fn))
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
def _reset(key="test-key"):
|
||||||
|
"""每例开跑前清场:配置好 key、清任务表、装默认桩。"""
|
||||||
|
config.XXL_TRIGGER_KEY = key
|
||||||
|
config.XXL_CALLBACK_MAX_RETRIES = 0
|
||||||
|
xxl._jobs.clear()
|
||||||
|
xxl._running_id = None
|
||||||
|
ran, sent = [], []
|
||||||
|
xxl._run_step = lambda cmd: (ran.append(list(cmd)), (0, "ok"))[1]
|
||||||
|
xxl._post_callback = lambda url, data: (sent.append((url, data)), "delivered http=200")[1]
|
||||||
|
return ran, sent
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_done(task_id, timeout=5.0):
|
||||||
|
"""等到任务连同结案回调都处理完(closed=True),避免断言撞上后台线程的时间差。"""
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
if xxl._jobs[task_id].get("closed"):
|
||||||
|
return xxl._jobs[task_id]
|
||||||
|
time.sleep(0.02)
|
||||||
|
raise AssertionError("任务超时未结束")
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger(**kw):
|
||||||
|
args = dict(callbackUrl=None, steps="build,plan,push-pool", date=None,
|
||||||
|
key="test-key", x_job_key=None)
|
||||||
|
args.update(kw)
|
||||||
|
return xxl.trigger_daily_build(**args)
|
||||||
|
|
||||||
|
|
||||||
|
@case("没配 XXL_TRIGGER_KEY → 整组端点禁用 (403),与决策系统同款安全默认")
|
||||||
|
def _():
|
||||||
|
_reset(key="")
|
||||||
|
try:
|
||||||
|
_trigger(key="whatever")
|
||||||
|
raise AssertionError("该 403 没 403")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 403, e
|
||||||
|
|
||||||
|
|
||||||
|
@case("key 不对 → 401;对了 → 受理并返回 task_id")
|
||||||
|
def _():
|
||||||
|
ran, _ = _reset()
|
||||||
|
try:
|
||||||
|
_trigger(key="wrong")
|
||||||
|
raise AssertionError("该 401 没 401")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 401, e
|
||||||
|
r = _trigger()
|
||||||
|
assert r["status"] == "accepted" and r["task_id"], r
|
||||||
|
_wait_done(r["task_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@case("三步按固定顺序跑完 → success,成功回调是空包")
|
||||||
|
def _():
|
||||||
|
ran, sent = _reset()
|
||||||
|
r = _trigger(callbackUrl="http://xxl/callback?logId=1")
|
||||||
|
job = _wait_done(r["task_id"])
|
||||||
|
assert job["state"] == "success", job
|
||||||
|
assert [c[1] for c in ran] == ["build", "plan", "push-pool"], ran
|
||||||
|
assert sent and sent[0][0].startswith("http://xxl/callback"), sent
|
||||||
|
assert sent[0][1] == b"", sent # 成功=空包,对端按成功结案
|
||||||
|
|
||||||
|
|
||||||
|
@case("中间一步失败 → 后面的步骤不跑,失败回调带 handleCode=500 与原因")
|
||||||
|
def _():
|
||||||
|
ran, sent = _reset()
|
||||||
|
xxl._run_step = lambda cmd: (ran.append(list(cmd)),
|
||||||
|
(1, "boom") if cmd[1] == "plan" else (0, "ok"))[1]
|
||||||
|
r = _trigger(callbackUrl="http://xxl/cb")
|
||||||
|
job = _wait_done(r["task_id"])
|
||||||
|
assert job["state"] == "failed" and "plan" in job["note"], job
|
||||||
|
assert [c[1] for c in ran] == ["build", "plan"], ran # push-pool 没跑
|
||||||
|
body = sent[0][1].decode()
|
||||||
|
assert "handleCode=500" in body and "plan" in body, body
|
||||||
|
|
||||||
|
|
||||||
|
@case("同一时刻只允许一条链: 在跑时再触发 → 409")
|
||||||
|
def _():
|
||||||
|
_reset()
|
||||||
|
gate = {"open": False}
|
||||||
|
|
||||||
|
def slow(cmd):
|
||||||
|
while not gate["open"]:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return 0, "ok"
|
||||||
|
xxl._run_step = slow
|
||||||
|
r1 = _trigger(steps="build")
|
||||||
|
try:
|
||||||
|
_trigger(steps="build")
|
||||||
|
raise AssertionError("该 409 没 409")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 409, e
|
||||||
|
finally:
|
||||||
|
gate["open"] = True
|
||||||
|
_wait_done(r1["task_id"])
|
||||||
|
r2 = _trigger(steps="build") # 跑完后可以再触发
|
||||||
|
_wait_done(r2["task_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@case("steps 白名单与顺序: 乱序请求被摆正, 未知步骤 400")
|
||||||
|
def _():
|
||||||
|
ran, _ = _reset()
|
||||||
|
r = _trigger(steps="push-pool,build")
|
||||||
|
_wait_done(r["task_id"])
|
||||||
|
assert [c[1] for c in ran] == ["build", "push-pool"], ran # 顺序固定
|
||||||
|
try:
|
||||||
|
_trigger(steps="build,发大财")
|
||||||
|
raise AssertionError("该 400 没 400")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 400, e
|
||||||
|
|
||||||
|
|
||||||
|
@case("date 参数透传到每一步命令")
|
||||||
|
def _():
|
||||||
|
ran, _ = _reset()
|
||||||
|
r = _trigger(steps="build,plan", date="2026-08-01")
|
||||||
|
_wait_done(r["task_id"])
|
||||||
|
for cmd in ran:
|
||||||
|
assert cmd[-2:] == ["--date", "2026-08-01"], cmd
|
||||||
|
|
||||||
|
|
||||||
|
@case("无 callbackUrl 也能正常结案 (只记日志, 平台走状态查询)")
|
||||||
|
def _():
|
||||||
|
_, sent = _reset()
|
||||||
|
r = _trigger(callbackUrl=None, steps="build")
|
||||||
|
job = _wait_done(r["task_id"])
|
||||||
|
assert job["state"] == "success" and not sent
|
||||||
|
|
||||||
|
|
||||||
|
@case("状态接口: 能查到 state/steps/tail; 不认识的 task_id 404; 也要带 key")
|
||||||
|
def _():
|
||||||
|
_reset()
|
||||||
|
r = _trigger(steps="build")
|
||||||
|
_wait_done(r["task_id"])
|
||||||
|
st = xxl.job_status(r["task_id"], key="test-key", x_job_key=None)
|
||||||
|
assert st["state"] == "success" and st["steps"] == ["build"] and st["tail"]
|
||||||
|
try:
|
||||||
|
xxl.job_status("no-such-id", key="test-key", x_job_key=None)
|
||||||
|
raise AssertionError("该 404 没 404")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 404, e
|
||||||
|
try:
|
||||||
|
xxl.job_status(r["task_id"], key="wrong", x_job_key=None)
|
||||||
|
raise AssertionError("该 401 没 401")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 401, e
|
||||||
|
|
||||||
|
|
||||||
|
@case("回调连接层失败: 重试次数用尽后放弃, 任务状态不受影响")
|
||||||
|
def _():
|
||||||
|
_reset()
|
||||||
|
config.XXL_CALLBACK_MAX_RETRIES = 0 # 失败一次即放弃, 免得测试睡 30 秒
|
||||||
|
|
||||||
|
def dead(url, data):
|
||||||
|
raise OSError("connect refused")
|
||||||
|
xxl._post_callback = dead
|
||||||
|
r = _trigger(callbackUrl="http://xxl/cb", steps="build")
|
||||||
|
job = _wait_done(r["task_id"])
|
||||||
|
assert job["state"] == "success", job # 回调失败不改任务结论
|
||||||
|
assert any("放弃" in ln for ln in job["tail"]), job["tail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- runner
|
||||||
|
def main():
|
||||||
|
passed, failed = 0, 0
|
||||||
|
for name, fn in RESULTS:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" PASS {name}")
|
||||||
|
passed += 1
|
||||||
|
except Exception:
|
||||||
|
print(f" FAIL {name}")
|
||||||
|
traceback.print_exc()
|
||||||
|
failed += 1
|
||||||
|
print("-" * 60)
|
||||||
|
if failed:
|
||||||
|
print(f"FAILED: {failed} / {passed + failed}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"ALL PASS ({passed} cases)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""统一任务调度平台(XXL-JOB)触发入口 —— 盘前链的外部拉起与结案回调。
|
||||||
|
|
||||||
|
参照决策系统 bionic_trader 的同款接入(app/api/xxl_jobs.py 的 trigger_daily_scan +
|
||||||
|
tasks_periodic 的 notify_xxl),对平台表现完全一致:
|
||||||
|
|
||||||
|
触发: GET|POST /api/v1/xxl/daily-build?key=...&steps=...&date=...
|
||||||
|
立即返回 200 + task_id;平台会自动在 URL 末尾追加 &callbackUrl=...
|
||||||
|
状态: GET /api/v1/xxl/status/{task_id} 回调未送达时的兜底查询
|
||||||
|
结案: 任务真正跑完后 POST callbackUrl ——
|
||||||
|
成功 = 空包(对端默认按成功结案);
|
||||||
|
失败 = 表单 {"handleCode": 500, "handleMsg": 原因}。
|
||||||
|
有 HTTP 响应(含 4xx/5xx)即算送达、不重试;只有连接层失败(不可达/超时)
|
||||||
|
才隔 30 秒重试,最多 XXL_CALLBACK_MAX_RETRIES 次,放弃后靠状态查询兜底。
|
||||||
|
|
||||||
|
与决策系统那套只有一处实现差别:桥没有 celery,任务由 API 进程起一条后台线程、
|
||||||
|
逐步以**子进程**方式执行 `python run.py <步骤>`。用子进程而不是进程内调用,是为了
|
||||||
|
保住「每次执行都重读 .env、重载代码」的既有性质——常驻 API 自身不会自动重载,
|
||||||
|
但它派生的子进程会,和宿主 cron 的 docker exec 语义完全一致。
|
||||||
|
|
||||||
|
安全默认与决策系统相同:.env 不配 XXL_TRIGGER_KEY,这一组端点整体禁用。
|
||||||
|
同一时刻只允许一条链在跑:已有任务未结束时再触发回 409,由平台按失败重试策略处理。
|
||||||
|
|
||||||
|
步骤白名单(steps 参数,逗号分隔,默认三步全跑,执行顺序固定):
|
||||||
|
build 因子构建(run.py build all --mode daily)
|
||||||
|
plan 生成当日选股计划文件
|
||||||
|
push-pool 计划写入股票池 + 触发决策系统增量补扫
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/xxl", tags=["xxl"])
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LOG_PATH = os.path.join(HERE, "data", "xxl_build.log")
|
||||||
|
|
||||||
|
# 步骤 → 命令(顺序即执行顺序;--date 由触发参数统一追加)
|
||||||
|
STEP_ORDER = ("build", "plan", "push-pool")
|
||||||
|
STEP_CMDS = {
|
||||||
|
"build": ["run.py", "build", "all", "--mode", "daily"],
|
||||||
|
"plan": ["run.py", "plan"],
|
||||||
|
"push-pool": ["run.py", "push-pool"],
|
||||||
|
}
|
||||||
|
STEP_TIMEOUT_SEC = 3600 # 单步上限一小时,防呆死(正常盘前链全程分钟级)
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_jobs: dict = {} # task_id -> 状态字典(常驻进程内存,重启即清空)
|
||||||
|
_running_id = None
|
||||||
|
_KEEP_JOBS = 20 # 只留最近 20 条状态,防内存慢涨
|
||||||
|
|
||||||
|
|
||||||
|
def _require_key(x_job_key, key):
|
||||||
|
cfg = config.XXL_TRIGGER_KEY
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=403,
|
||||||
|
detail="XXL trigger disabled: XXL_TRIGGER_KEY not set")
|
||||||
|
if (x_job_key or key) != cfg:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid key")
|
||||||
|
|
||||||
|
|
||||||
|
def _log(job, line: str):
|
||||||
|
stamp = dt.datetime.now().strftime("%H:%M:%S")
|
||||||
|
job["tail"].append(f"[{stamp}] {line}")
|
||||||
|
job["tail"] = job["tail"][-60:]
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
||||||
|
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"{dt.datetime.now().isoformat(timespec='seconds')} "
|
||||||
|
f"[{job['task_id'][:8]}] {line}\n")
|
||||||
|
except Exception: # noqa: BLE001 —— 落盘日志失败不影响任务本体
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_step(cmd: list) -> tuple:
|
||||||
|
"""跑一步(子进程)。单测在这里打桩。返回 (退出码, 合并输出)。"""
|
||||||
|
p = subprocess.run([sys.executable] + cmd, cwd=HERE, capture_output=True,
|
||||||
|
text=True, timeout=STEP_TIMEOUT_SEC)
|
||||||
|
return p.returncode, (p.stdout or "") + (p.stderr or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_callback(url: str, data: bytes) -> str:
|
||||||
|
"""回调一跳(单测在这里打桩)。返回送达说明;连接层失败抛异常。"""
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=config.XXL_CALLBACK_READ_TIMEOUT) as resp:
|
||||||
|
return f"delivered http={resp.status}"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return f"delivered http={e.code}" # 对端有响应即算送达,不重试
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(job, callback_url: str, ok: bool, note: str):
|
||||||
|
"""结案回调,契约与决策系统 notify_xxl 一致。"""
|
||||||
|
if not callback_url:
|
||||||
|
_log(job, "无 callbackUrl,跳过结案回调(平台可查状态接口)")
|
||||||
|
return
|
||||||
|
data = (b"" if ok else urllib.parse.urlencode(
|
||||||
|
{"handleCode": 500, "handleMsg": (note or "job failed")[:500]}).encode())
|
||||||
|
for attempt in range(config.XXL_CALLBACK_MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
_log(job, f"结案回调 ok={ok}: {_post_callback(callback_url, data)}")
|
||||||
|
return
|
||||||
|
except Exception as e: # noqa: BLE001 —— 连接层失败才会走到这
|
||||||
|
if attempt >= config.XXL_CALLBACK_MAX_RETRIES:
|
||||||
|
_log(job, f"结案回调最终未达,放弃(靠状态查询兜底): {e!r}")
|
||||||
|
return
|
||||||
|
_log(job, f"结案回调连接失败,30 秒后第 {attempt + 1} 次重试: {e!r}")
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_job(task_id: str, steps: list, date, callback_url):
|
||||||
|
global _running_id
|
||||||
|
job = _jobs[task_id]
|
||||||
|
ok, note = True, ""
|
||||||
|
try:
|
||||||
|
for step in steps:
|
||||||
|
job["step"] = step
|
||||||
|
cmd = list(STEP_CMDS[step]) + (["--date", date] if date else [])
|
||||||
|
_log(job, f"开始 {step}: python {' '.join(cmd)}")
|
||||||
|
try:
|
||||||
|
code, out = _run_step(cmd)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
ok, note = False, f"{step} 超过 {STEP_TIMEOUT_SEC} 秒未结束,判失败"
|
||||||
|
_log(job, note)
|
||||||
|
break
|
||||||
|
for line in (out or "").strip().splitlines()[-15:]:
|
||||||
|
_log(job, f" {line}")
|
||||||
|
if code != 0:
|
||||||
|
ok, note = False, f"{step} 退出码 {code}"
|
||||||
|
_log(job, note)
|
||||||
|
break
|
||||||
|
_log(job, f"完成 {step}")
|
||||||
|
except Exception as e: # noqa: BLE001 —— 线程里绝不让异常无声消失
|
||||||
|
ok, note = False, f"意外异常: {type(e).__name__}: {e}"
|
||||||
|
_log(job, note)
|
||||||
|
job.update(state="success" if ok else "failed", note=note,
|
||||||
|
ended_at=dt.datetime.now().isoformat(timespec="seconds"), step=None)
|
||||||
|
with _lock:
|
||||||
|
_running_id = None
|
||||||
|
_notify(job, callback_url, ok, note)
|
||||||
|
job["closed"] = True # 结案回调也处理完了(送达或放弃),状态接口可据此判断
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/daily-build", methods=["GET", "POST"])
|
||||||
|
def trigger_daily_build(
|
||||||
|
callbackUrl: str = Query(None, description="平台下发的结案回调地址(自动追加)"),
|
||||||
|
steps: str = Query("build,plan,push-pool",
|
||||||
|
description="要跑哪几步,逗号分隔;顺序固定 build→plan→push-pool"),
|
||||||
|
date: str = Query(None, description="补跑指定数据日 YYYY-MM-DD;空=最新数据日"),
|
||||||
|
key: str = Query(None),
|
||||||
|
x_job_key: str = Header(None),
|
||||||
|
):
|
||||||
|
"""触发盘前链。立即返回 task_id,跑完由结案回调通知平台。
|
||||||
|
|
||||||
|
平台执行参数示例(方式 GET,key 写进 URL,平台会自己追加 callbackUrl):
|
||||||
|
http://<桥机IP>:8300/api/v1/xxl/daily-build?key=<KEY>
|
||||||
|
"""
|
||||||
|
global _running_id
|
||||||
|
_require_key(x_job_key, key)
|
||||||
|
wanted = [s.strip() for s in (steps or "").split(",") if s.strip()]
|
||||||
|
bad = [s for s in wanted if s not in STEP_CMDS]
|
||||||
|
if bad or not wanted:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail=f"steps 只认 {list(STEP_ORDER)},收到 {wanted}")
|
||||||
|
ordered = [s for s in STEP_ORDER if s in wanted]
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
if _running_id and _jobs.get(_running_id, {}).get("state") == "running":
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"已有任务在跑(task_id={_running_id}),本次拒绝")
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
_jobs[task_id] = {"task_id": task_id, "state": "running", "steps": ordered,
|
||||||
|
"step": None, "date": date, "note": "", "tail": [],
|
||||||
|
"started_at": dt.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"ended_at": None, "closed": False}
|
||||||
|
while len(_jobs) > _KEEP_JOBS: # 只留最近 N 条
|
||||||
|
oldest = next(iter(_jobs))
|
||||||
|
if oldest == task_id:
|
||||||
|
break
|
||||||
|
_jobs.pop(oldest)
|
||||||
|
_running_id = task_id
|
||||||
|
|
||||||
|
threading.Thread(target=_run_job, args=(task_id, ordered, date, callbackUrl),
|
||||||
|
daemon=True).start()
|
||||||
|
return {"status": "accepted", "task_id": task_id, "steps": ordered, "date": date}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{task_id}")
|
||||||
|
def job_status(task_id: str, key: str = Query(None), x_job_key: str = Header(None)):
|
||||||
|
"""状态兜底:running / success / failed + 当前步骤 + 最近输出。"""
|
||||||
|
_require_key(x_job_key, key)
|
||||||
|
job = _jobs.get(task_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail="task_id 不存在(API 重启会清空历史状态)")
|
||||||
|
return job
|
||||||
Loading…
Reference in New Issue