web_api.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. """
  2. demand Web API(异步任务:发起 -> 立即返回 task_id -> 另一个接口查询状态)
  3. """
  4. import asyncio
  5. import os
  6. import importlib
  7. import sys
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. from typing import Any, Literal, Optional
  11. from zoneinfo import ZoneInfo
  12. from fastapi import FastAPI, HTTPException
  13. from pydantic import BaseModel
  14. from examples.demand.db_manager import exist_cluster_tree
  15. # 添加项目根目录到 Python 路径(与 run.py 保持一致)
  16. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  17. from examples.demand.zengzhang_prepare import zengzhang_prepare
  18. from examples.demand.changwen_prepare import changwen_prepare
  19. from examples.demand.mysql import mysql_db
  20. from examples.demand.piaoquan_prepare import piaoquan_prepare
  21. from examples.demand.run import _create_demand_task, main as run_demand
  22. from examples.demand.zaozhongwanhao_extra import (
  23. CLUSTER_NAME as ZAOZHONGWANHAO_CLUSTER,
  24. maybe_finish_zaoan_from_cluster,
  25. maybe_run_zaozhongwanhao_extra,
  26. schedule_has_zaozhongwanhao,
  27. )
  28. app = FastAPI(title="demand web api")
  29. GLOBAL_TREE_SCHEDULE_ITEM = {
  30. "cluster_name": "全局树",
  31. "platform_type": "piaoquan",
  32. "count": 50,
  33. }
  34. # APScheduler:使用动态导入避免环境未安装时直接导入失败
  35. try:
  36. _aps_asyncio_mod = importlib.import_module("apscheduler.schedulers.asyncio")
  37. _aps_cron_mod = importlib.import_module("apscheduler.triggers.cron")
  38. AsyncIOScheduler = getattr(_aps_asyncio_mod, "AsyncIOScheduler")
  39. CronTrigger = getattr(_aps_cron_mod, "CronTrigger")
  40. except Exception: # pragma: no cover
  41. AsyncIOScheduler = None # type: ignore[assignment]
  42. CronTrigger = None # type: ignore[assignment]
  43. class DemandStartRequest(BaseModel):
  44. cluster_name: str
  45. platform_type: Literal["piaoquan", "changwen"]
  46. count: int
  47. def _get_demand_schedule_cluster_platform_list() -> list[dict]:
  48. """动态获取定时任务列表。"""
  49. schedule_items: list[dict] = []
  50. try:
  51. # 延迟导入:避免服务启动时因 ODPS 依赖缺失直接失败
  52. from examples.demand.data_query_tools import get_demand_merge_level2_names
  53. schedule_items = get_demand_merge_level2_names() or []
  54. except Exception as e: # pragma: no cover
  55. print(f"获取定时任务列表失败: {e}")
  56. # 固定补充:全局树每天都要产出 50 条需求(以固定值覆盖动态值)
  57. schedule_items = [
  58. item for item in schedule_items
  59. if not (
  60. item.get("cluster_name") == GLOBAL_TREE_SCHEDULE_ITEM["cluster_name"]
  61. and item.get("platform_type") == GLOBAL_TREE_SCHEDULE_ITEM["platform_type"]
  62. )
  63. ]
  64. schedule_items.append(GLOBAL_TREE_SCHEDULE_ITEM.copy())
  65. return schedule_items
  66. # 是否开启定时任务(可选,通过环境变量覆盖)
  67. DEMAND_SCHEDULER_ENABLED: bool = os.getenv("DEMAND_SCHEDULER_ENABLED", "1").strip() == "1"
  68. DEMAND_SCHEDULER_START_HOUR: int = 2
  69. # 定时任务统一使用北京时间,避免服务器时区(如 UTC)带来的偏差
  70. BEIJING_TZ = ZoneInfo("Asia/Shanghai")
  71. def _get_today_time_window(now: datetime) -> tuple[datetime, datetime]:
  72. """返回今天的 [start, end) 时间窗口(本地时区)。"""
  73. start_of_today = datetime(year=now.year, month=now.month, day=now.day, tzinfo=now.tzinfo)
  74. end_of_today = start_of_today + timedelta(days=1)
  75. return start_of_today, end_of_today
  76. async def demand_start_sync(cluster_name: str, platform_type: Literal["piaoquan", "changwen"], count) -> dict:
  77. """
  78. 与 /demand/start 同一执行链路,但不创建后台任务:prepare -> create demand_task -> 串行 await run_demand。
  79. """
  80. # prepare 阶段是同步的(当前示例代码为 sync),这里保持同步串行语义
  81. execution_id = None
  82. task_id: Optional[int] = None
  83. try:
  84. if platform_type == "piaoquan":
  85. exist = exist_cluster_tree(cluster_name)
  86. if not exist:
  87. raise ValueError("获取聚类树失败")
  88. execution_id = piaoquan_prepare(cluster_name)
  89. elif platform_type == "changwen":
  90. execution_id = changwen_prepare(cluster_name)
  91. elif platform_type == "zengzhang":
  92. execution_id = zengzhang_prepare(cluster_name)
  93. if not execution_id:
  94. raise ValueError("获取 execution_id 失败")
  95. task_name = cluster_name[:32] if cluster_name else None
  96. task_id = _create_demand_task(
  97. execution_id=execution_id,
  98. name=task_name,
  99. platform=platform_type,
  100. )
  101. if not task_id:
  102. raise ValueError("创建 demand_task 失败")
  103. # run_once 内部 finally 会把 task 状态写回 MySQL
  104. result = await run_demand(
  105. cluster_name,
  106. platform_type,
  107. count,
  108. execution_id=execution_id,
  109. task_id=task_id,
  110. )
  111. return {"ok": True, "message": "调用成功", "task_id": task_id, "execution_id": execution_id, "result": result}
  112. except Exception as e:
  113. return {
  114. "ok": False,
  115. "message": f"执行失败: {e}",
  116. "task_id": task_id,
  117. "execution_id": execution_id,
  118. "result": None,
  119. }
  120. def _today_has_status_0_or_1(cluster_name: str, platform_type: str, now: datetime) -> bool:
  121. """
  122. 查找 demand_task:
  123. - 限制为今天(create_time)
  124. - name 与 platform 精确匹配
  125. - 若存在 status 为 0 或 1 的记录,则跳过
  126. """
  127. start_of_today, end_of_today = _get_today_time_window(now)
  128. # MySQL DATETIME 一般按无时区存储,这里使用北京时间对应的“本地时间”窗口做过滤
  129. start_of_today_naive = start_of_today.replace(tzinfo=None)
  130. end_of_today_naive = end_of_today.replace(tzinfo=None)
  131. return mysql_db.exists(
  132. "demand_task",
  133. where=(
  134. "name = %s "
  135. "AND platform = %s "
  136. "AND status IN (0, 1) "
  137. "AND create_time >= %s "
  138. "AND create_time < %s"
  139. ),
  140. where_params=(
  141. str(cluster_name)[:32],
  142. str(platform_type)[:32],
  143. start_of_today_naive,
  144. end_of_today_naive,
  145. ),
  146. )
  147. async def demand_scheduled_run_once() -> None:
  148. """
  149. 任务批处理(串行):
  150. 遍历配置列表 -> 查当天 demand_task -> 匹配 cluster_name/name & platform_type/platform
  151. 若存在 status=0 或 1 的记录则跳过;否则执行一次 demand_start_sync。
  152. 「早中晚好」:有品类产出则按权重取 top10;没有则只生成 10 条。再筛早安策略入库。
  153. """
  154. # ODPS 查询是阻塞调用,放到线程里避免阻塞事件循环
  155. demand_schedule_cluster_platform_list = await asyncio.to_thread(_get_demand_schedule_cluster_platform_list)
  156. demand_schedule_cluster_platform_list = demand_schedule_cluster_platform_list or []
  157. now = datetime.now(BEIJING_TZ)
  158. has_zao = schedule_has_zaozhongwanhao(demand_schedule_cluster_platform_list)
  159. await maybe_run_zaozhongwanhao_extra(demand_schedule_cluster_platform_list, now=now)
  160. zao_execution_id = None
  161. for item in demand_schedule_cluster_platform_list:
  162. cluster_name = item.get("cluster_name")
  163. platform_type = item.get("platform_type")
  164. count = item.get("count")
  165. if not cluster_name or platform_type not in ("piaoquan", "changwen"):
  166. continue
  167. if _today_has_status_0_or_1(cluster_name, platform_type, now=now):
  168. print(f"[scheduler] skip: cluster={cluster_name}, platform={platform_type} (today has status 0/1)")
  169. continue
  170. print(f"[scheduler] run: cluster={cluster_name}, platform={platform_type}, count={count}")
  171. result = await demand_start_sync(cluster_name=cluster_name, platform_type=platform_type, count=count) # 串行执行
  172. if str(cluster_name).strip() == ZAOZHONGWANHAO_CLUSTER and isinstance(result, dict):
  173. zao_execution_id = result.get("execution_id") or zao_execution_id
  174. if has_zao:
  175. await maybe_finish_zaoan_from_cluster(execution_id=zao_execution_id, now=now)
  176. _demand_scheduler: Optional[Any] = None
  177. _demand_scheduler_lock = asyncio.Lock()
  178. async def _demand_scheduler_job() -> None:
  179. """
  180. 定时任务 job:
  181. - 串行执行(防止并发)
  182. - 遍历配置 -> 今日过滤 demand_task -> 跳过/执行
  183. """
  184. if _demand_scheduler_lock.locked():
  185. return
  186. # 指定日期跳过:北京时间 2026-04-08 不执行定时任务
  187. if datetime.now(BEIJING_TZ).date() == datetime(2026, 4, 8).date():
  188. print("[scheduler] skip: 2026-04-08")
  189. return
  190. async with _demand_scheduler_lock:
  191. await demand_scheduled_run_once()
  192. @app.post("/demand/start")
  193. async def demand_start(req: DemandStartRequest):
  194. # 注意:这里会同步计算 execution_id(prepare 阶段),随后 run_once 放到后台异步执行。
  195. if req.platform_type == "piaoquan":
  196. execution_id = piaoquan_prepare(req.cluster_name)
  197. else:
  198. execution_id = changwen_prepare(req.cluster_name)
  199. if not execution_id:
  200. raise HTTPException(status_code=400, detail="获取 execution_id 失败")
  201. task_name = req.cluster_name[:32] if req.cluster_name else None
  202. task_id = _create_demand_task(
  203. execution_id=execution_id,
  204. name=task_name,
  205. platform=req.platform_type,
  206. )
  207. if not task_id:
  208. raise HTTPException(status_code=500, detail="创建 demand_task 失败")
  209. async def _job():
  210. # run_once 内部会在 finally 里把 task 状态写回 MySQL。
  211. await run_demand(
  212. req.cluster_name,
  213. req.platform_type,
  214. req.count,
  215. execution_id=execution_id,
  216. task_id=task_id,
  217. )
  218. asyncio.create_task(_job())
  219. return {"ok": True, "message": "调用成功", "task_id": task_id, "execution_id": execution_id}
  220. @app.on_event("startup")
  221. async def _start_demand_scheduler() -> None:
  222. """启动定时任务(北京时间:2:00–22:30 每 30 分钟,不含 23 点与 0 点)。"""
  223. global _demand_scheduler
  224. if not DEMAND_SCHEDULER_ENABLED:
  225. return
  226. if _demand_scheduler is not None:
  227. return
  228. if AsyncIOScheduler is None or CronTrigger is None:
  229. # 依赖未安装则跳过定时任务
  230. print("[scheduler] apscheduler 未安装,跳过定时任务启动")
  231. return
  232. scheduler = AsyncIOScheduler(timezone=BEIJING_TZ)
  233. start_h = DEMAND_SCHEDULER_START_HOUR
  234. # DEMAND_SCHEDULER_START_HOUR–22:每 30 分钟(默认 2:00–22:30,不含 23 点)
  235. if start_h > 22:
  236. print(f"[scheduler] DEMAND_SCHEDULER_START_HOUR={start_h} > 22,无法注册 cron,跳过定时任务")
  237. else:
  238. scheduler.add_job(
  239. func=_demand_scheduler_job,
  240. trigger=CronTrigger(hour=f"{start_h}-22", minute="0,30", timezone=BEIJING_TZ),
  241. id="demand_scheduler_job_main",
  242. replace_existing=True,
  243. max_instances=1,
  244. coalesce=True,
  245. )
  246. scheduler.start()
  247. _demand_scheduler = scheduler
  248. @app.get("/demand/task/{task_id}/status")
  249. def demand_task_status(task_id: int, max_log_chars: int = 2000):
  250. row = mysql_db.select_one(
  251. "demand_task",
  252. columns="id, execution_id, name, platform, status, log",
  253. where="id = %s",
  254. where_params=(task_id,),
  255. )
  256. if not row:
  257. raise HTTPException(status_code=404, detail="task not found")
  258. status = int(row.get("status") or 0)
  259. status_map = {0: "running", 1: "completed", 2: "failed"}
  260. log_text = row.get("log") or ""
  261. if max_log_chars and isinstance(log_text, str) and len(log_text) > max_log_chars:
  262. log_text = log_text[:max_log_chars] + "...(truncated)"
  263. execution_id = row.get("execution_id")
  264. final_text: Optional[str] = None
  265. if status == 1 and execution_id:
  266. try:
  267. result_path = Path(__file__).parent / "output" / str(execution_id) / "result.txt"
  268. if result_path.exists():
  269. with open(result_path, "r", encoding="utf-8") as f:
  270. final_text = f.read()
  271. except Exception:
  272. final_text = None
  273. return {
  274. "task_id": task_id,
  275. "execution_id": execution_id,
  276. "name": row.get("name"),
  277. "platform": row.get("platform"),
  278. "status": status,
  279. "status_text": status_map.get(status, "unknown"),
  280. "final_text": final_text,
  281. "log": log_text,
  282. }
  283. @app.get("/demand/tasks")
  284. def demand_tasks(
  285. status: Optional[int] = None,
  286. name: Optional[str] = None,
  287. platform_type: Optional[str] = None,
  288. page: int = 1,
  289. page_size: int = 20,
  290. ):
  291. where_parts: list[str] = []
  292. where_params: list = []
  293. if status is not None:
  294. status_int = int(status)
  295. if status_int not in (0, 1, 2):
  296. raise HTTPException(status_code=400, detail="status 必须为 0/1/2")
  297. where_parts.append("status = %s")
  298. where_params.append(status_int)
  299. if name:
  300. name_str = str(name).strip()
  301. if name_str:
  302. # 支持模糊匹配:根据需求名称字段(varchar(32))
  303. where_parts.append("name LIKE %s")
  304. where_params.append(f"%{name_str}%")
  305. if platform_type:
  306. platform_str = str(platform_type).strip()
  307. if platform_str:
  308. where_parts.append("platform = %s")
  309. where_params.append(platform_str)
  310. where = " AND ".join(where_parts)
  311. params = tuple(where_params) if where_params else None
  312. data = mysql_db.paginate(
  313. "demand_task",
  314. page=page,
  315. page_size=page_size,
  316. columns="id, execution_id, name, platform, status, create_time, update_time",
  317. where=where,
  318. where_params=params,
  319. order_by="id DESC",
  320. )
  321. # 返回分页结构(data + pagination),便于前端直接展示
  322. return data
  323. def run_server():
  324. import uvicorn
  325. uvicorn.run(app, host="0.0.0.0", port=7000)
  326. if __name__ == "__main__":
  327. run_server()