decode_dispatch_job.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import json
  2. from datetime import datetime
  3. from typing import Any, Dict, List, Optional, Tuple
  4. from zoneinfo import ZoneInfo
  5. import requests
  6. from scheduler.odps_fetch import fetch_priority_posts
  7. from utils.scheduler_logger import get_scheduler_logger
  8. from utils.sync_mysql_help import mysql
  9. logger = get_scheduler_logger()
  10. CONFIG_ID = "57"
  11. DECODE_URL = "https://aigc-api.aiddit.com/aigc/api/task/decode"
  12. DECODE_RESULT_URL = "https://aigc-api.aiddit.com/aigc/api/task/decode/result"
  13. BATCH_SIZE = 10
  14. ODPS_PAGE_SIZE = 200
  15. RESULT_POLL_CHUNK = 50
  16. def _map_api_status_to_int(api_status: str, vid: str) -> int:
  17. """Map upstream status string to DB status: 0待执行 1执行中 2成功 3失败."""
  18. s = (api_status or "").strip().upper()
  19. if s == "SUCCESS":
  20. return 2
  21. if s in ("FAILED", "FAILURE", "ERROR", "FAIL"):
  22. return 3
  23. if s in ("RUNNING", "PROCESSING", "DOING"):
  24. return 1
  25. if s in ("PENDING", "WAITING", "INIT", "QUEUED"):
  26. return 0
  27. if not s:
  28. return 0
  29. logger.warning("未知解码状态,按执行中处理 status={} vid={}", api_status, vid)
  30. return 1
  31. def _safe_json_loads(text: Optional[str]) -> Dict[str, Any]:
  32. if not text:
  33. return {}
  34. try:
  35. data = json.loads(text)
  36. return data if isinstance(data, dict) else {}
  37. except Exception:
  38. return {}
  39. def _today_dt() -> str:
  40. return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
  41. def _is_decode_submit_open() -> bool:
  42. """
  43. Gate for submitting NEW decode tasks.
  44. Only controls whether to submit; polling/querying existing tasks is unaffected.
  45. """
  46. sql = """
  47. SELECT is_open
  48. FROM aigc_topic_decode_task_oprate
  49. ORDER BY id DESC
  50. LIMIT 1
  51. """
  52. try:
  53. row = mysql.fetchone(sql)
  54. if not row:
  55. # Fail-open if table is empty to avoid blocking by default.
  56. return True
  57. return int(row.get("is_open") or 0) == 1
  58. except Exception as exc:
  59. # Conservative: if we cannot confirm switch is open, skip submit this cycle.
  60. logger.exception("查询解构开关失败,本轮不发起新解构任务: {}", exc)
  61. return False
  62. def _fetch_today_pending_vids(dt: str) -> List[str]:
  63. sql = """
  64. SELECT DISTINCT vid
  65. FROM aigc_topic_decode_task_result
  66. WHERE dt = %s AND status IN (0, 1) AND vid IS NOT NULL AND vid != ''
  67. ORDER BY vid
  68. """
  69. rows = mysql.fetchall(sql, (dt,))
  70. return [str(row["vid"]) for row in rows if row.get("vid")]
  71. def _count_today_non_terminal(dt: str) -> int:
  72. sql = """
  73. SELECT COUNT(1) AS total
  74. FROM aigc_topic_decode_task_result
  75. WHERE dt = %s AND status IN (0, 1)
  76. """
  77. result = mysql.fetchone(sql, (dt,))
  78. return int((result or {}).get("total", 0))
  79. def _submit_decode_result_chunk(
  80. channel_content_ids: List[str],
  81. ) -> Tuple[bool, str, Dict[str, Any]]:
  82. payload = {"params": {"configId": CONFIG_ID, "channelContentIds": channel_content_ids}}
  83. try:
  84. resp = requests.post(DECODE_RESULT_URL, json=payload, timeout=60)
  85. if resp.status_code != 200:
  86. return False, f"http_status_{resp.status_code}", {}
  87. body = resp.json()
  88. ok = body.get("code") == 0
  89. return ok, body.get("msg") or "", body
  90. except Exception as exc:
  91. return False, str(exc), {}
  92. def _apply_result_row_to_db(dt: str, item: Dict[str, Any]) -> None:
  93. vid = str(item.get("channelContentId") or "").strip()
  94. if not vid:
  95. return
  96. api_status_raw = item.get("status") or ""
  97. err_msg = (item.get("err_msg") or item.get("errorMessage") or "") or ""
  98. data_content = item.get("dataContent")
  99. if data_content is not None and not isinstance(data_content, str):
  100. data_content = json.dumps(data_content, ensure_ascii=False)
  101. html = item.get("html")
  102. base_status = _map_api_status_to_int(str(api_status_raw), vid)
  103. sql = """
  104. UPDATE aigc_topic_decode_task_result
  105. SET status = %s,
  106. err_msg = %s,
  107. data_content = %s,
  108. html = %s
  109. WHERE dt = %s AND vid = %s
  110. """
  111. mysql.execute(
  112. sql,
  113. (
  114. base_status,
  115. err_msg[:512] if err_msg else "",
  116. data_content if data_content is not None else "",
  117. html if html is not None else None,
  118. dt,
  119. vid,
  120. ),
  121. )
  122. def _poll_decode_results_for_today(dt: str, vids: List[str]) -> None:
  123. if not vids:
  124. return
  125. total = len(vids)
  126. logger.info("开始查询解码结果 dt={} 总vid数={}", dt, total)
  127. overall_success = 0
  128. overall_returned = 0
  129. for i in range(0, total, RESULT_POLL_CHUNK):
  130. chunk = vids[i : i + RESULT_POLL_CHUNK]
  131. logger.info(
  132. "查询解码结果 dt={} 分片序号={} 分片大小={} 总数={}",
  133. dt,
  134. i // RESULT_POLL_CHUNK,
  135. len(chunk),
  136. total,
  137. )
  138. ok, msg, body = _submit_decode_result_chunk(chunk)
  139. if not ok:
  140. logger.error(
  141. "查询解码结果接口失败 dt={} msg={} body={}",
  142. dt,
  143. msg,
  144. body,
  145. )
  146. continue
  147. data_list = body.get("data")
  148. if not isinstance(data_list, list):
  149. logger.warning("查询解码结果返回中缺少data列表 body={}", body)
  150. continue
  151. chunk_success = 0
  152. returned_ids = {str(x.get("channelContentId") or "") for x in data_list}
  153. missing = set(chunk) - returned_ids
  154. if missing:
  155. logger.warning(
  156. "查询解码结果返回缺少{}个vid,示例={}",
  157. len(missing),
  158. list(missing)[:5],
  159. )
  160. for item in data_list:
  161. if not isinstance(item, dict):
  162. continue
  163. vid = str(item.get("channelContentId") or "").strip()
  164. api_status = str(item.get("status") or "")
  165. mapped_status = _map_api_status_to_int(api_status, vid)
  166. if mapped_status == 2:
  167. chunk_success += 1
  168. err_msg = (item.get("err_msg") or item.get("errorMessage") or "") or ""
  169. logger.info(
  170. "解码结果明细 dt={} vid={} 接口状态={} 映射状态={} 错误信息={}",
  171. dt,
  172. vid,
  173. api_status,
  174. mapped_status,
  175. err_msg[:512] if err_msg else "",
  176. )
  177. _apply_result_row_to_db(dt, item)
  178. overall_success += chunk_success
  179. overall_returned += len(data_list)
  180. logger.info(
  181. "解码结果分片处理完成 dt={} 查询数={} 返回数={} 成功数={}",
  182. dt,
  183. len(chunk),
  184. len(data_list),
  185. chunk_success,
  186. )
  187. logger.info(
  188. "解码结果查询完成 dt={} 查询总数={} 返回总数={} 成功总数={}",
  189. dt,
  190. total,
  191. overall_returned,
  192. overall_success,
  193. )
  194. def _build_posts_payload(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  195. posts: List[Dict[str, Any]] = []
  196. for item in records:
  197. extend_raw = item.get("extend")
  198. extend_obj: Dict[str, Any]
  199. if isinstance(extend_raw, dict):
  200. extend_obj = extend_raw
  201. else:
  202. extend_obj = _safe_json_loads(str(extend_raw)) if extend_raw is not None else {}
  203. cover_url = extend_obj.get("cover_url") or ""
  204. images = [cover_url] if cover_url else []
  205. posts.append(
  206. {
  207. "channelContentId": item.get("vid") or "",
  208. "title": item.get("title") or "",
  209. "video": item.get("url") or "",
  210. "images": images,
  211. "contentModal": 4,
  212. "channel": 10,
  213. }
  214. )
  215. return posts
  216. def _submit_decode(posts: List[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]:
  217. payload = {"params": {"configId": CONFIG_ID, "posts": posts}}
  218. try:
  219. resp = requests.post(DECODE_URL, json=payload, timeout=60)
  220. if resp.status_code != 200:
  221. return False, f"http_status_{resp.status_code}", {}
  222. body = resp.json()
  223. ok = body.get("code") == 0
  224. return ok, body.get("msg") or "", body
  225. except Exception as exc:
  226. return False, str(exc), {}
  227. def _load_existing_vids(dt: str) -> set[str]:
  228. sql = """
  229. SELECT DISTINCT vid
  230. FROM aigc_topic_decode_task_result
  231. WHERE dt = %s AND vid IS NOT NULL AND vid != ''
  232. """
  233. rows = mysql.fetchall(sql, (dt,))
  234. return {str(row["vid"]) for row in rows if row.get("vid")}
  235. def _pick_candidate_records(dt: str, batch_size: int = BATCH_SIZE) -> List[Dict[str, Any]]:
  236. existing_vids = _load_existing_vids(dt)
  237. selected: List[Dict[str, Any]] = []
  238. selected_vids: set[str] = set()
  239. offset = 0
  240. while len(selected) < batch_size:
  241. page = fetch_priority_posts(limit=ODPS_PAGE_SIZE, offset=offset, dt=dt)
  242. if not page:
  243. break
  244. for item in page:
  245. vid = str(item.get("vid") or "")
  246. if not vid or vid in existing_vids or vid in selected_vids:
  247. continue
  248. selected.append(item)
  249. selected_vids.add(vid)
  250. if len(selected) >= batch_size:
  251. break
  252. offset += ODPS_PAGE_SIZE
  253. logger.info(
  254. "候选数据筛选完成 dt={} 已选数量={} 扫描offset={}",
  255. dt,
  256. len(selected),
  257. offset,
  258. )
  259. if selected:
  260. vid_title_pairs = [
  261. {"vid": str(item.get("vid") or ""), "title": item.get("title") or ""}
  262. for item in selected
  263. ]
  264. logger.info("已选候选数据 dt={} items={}", dt, vid_title_pairs)
  265. return selected
  266. def _row_status_after_decode_submit(
  267. vid: str, row_in_resp: Optional[Dict[str, Any]], full_body: Dict[str, Any]
  268. ) -> Tuple[int, str, str, Optional[str]]:
  269. """Returns (status, err_msg, data_content, html) for INSERT."""
  270. if not row_in_resp:
  271. payload = json.dumps({"decode_submit_response": full_body}, ensure_ascii=False)
  272. return 1, "", payload, None
  273. api_status_raw = row_in_resp.get("status") or ""
  274. err_msg = (row_in_resp.get("err_msg") or row_in_resp.get("errorMessage") or "") or ""
  275. mapped = _map_api_status_to_int(str(api_status_raw), vid)
  276. payload = json.dumps(
  277. {"decode_submit_item": row_in_resp, "decode_submit_response": full_body},
  278. ensure_ascii=False,
  279. )
  280. if mapped == 3:
  281. return 3, err_msg[:512], payload, None
  282. if mapped == 2:
  283. # New submit API only returns status/errorMessage.
  284. # Keep SUCCESS as terminal success; detailed result is queried via decode/result.
  285. return 2, "", payload, None
  286. if mapped == 0:
  287. return 0, err_msg[:512], payload, None
  288. return 1, err_msg[:512], payload, None
  289. def _insert_task_result_row(
  290. source: Dict[str, Any],
  291. status: int,
  292. err_msg: str,
  293. data_content: str,
  294. html: Optional[str],
  295. ) -> None:
  296. extend = _safe_json_loads(source.get("extend"))
  297. cover_url = extend.get("cover_url", "")
  298. cover_text = cover_url if isinstance(cover_url, str) else ""
  299. images_text = source.get("url") or ""
  300. sql = """
  301. INSERT INTO aigc_topic_decode_task_result
  302. (task_id, status, err_msg, vid, title, cover, video_url, images, type, channel, cate1, cate2, dt, data_content, html)
  303. VALUES
  304. (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  305. """
  306. params = (
  307. None,
  308. status,
  309. err_msg or "",
  310. str(source.get("vid") or ""),
  311. source.get("title") or "",
  312. cover_text,
  313. source.get("url") or "",
  314. images_text,
  315. source.get("type") or "",
  316. source.get("channel") or "",
  317. source.get("cate1") or "",
  318. source.get("cate2") or "",
  319. source.get("dt") or _today_dt(),
  320. data_content,
  321. html,
  322. )
  323. mysql.execute(sql, params)
  324. def _insert_rows_after_decode_submit(records: List[Dict[str, Any]], body: Dict[str, Any]) -> None:
  325. data_list = body.get("data") if isinstance(body.get("data"), list) else []
  326. by_vid = {str(x.get("channelContentId") or ""): x for x in data_list if isinstance(x, dict)}
  327. for item in records:
  328. vid = str(item.get("vid") or "")
  329. row = by_vid.get(vid)
  330. status, err_msg, data_content, html = _row_status_after_decode_submit(vid, row, body)
  331. _insert_task_result_row(item, status, err_msg, data_content, html)
  332. def run_decode_dispatch_job() -> None:
  333. logger.info("解码调度任务开始执行")
  334. try:
  335. dt = _today_dt()
  336. # Startup guard: if there are in-flight tasks today, poll only in this run.
  337. # New batch submit will wait for next scheduler cycle after all are terminal.
  338. initial_non_terminal = _count_today_non_terminal(dt)
  339. if initial_non_terminal > 0:
  340. logger.info(
  341. "启动时发现当天存在进行中任务,本轮仅查询不发起新批次 dt={} count={}",
  342. dt,
  343. initial_non_terminal,
  344. )
  345. pending_vids = _fetch_today_pending_vids(dt)
  346. if pending_vids:
  347. logger.info("查询当天待执行/执行中记录 dt={} count={}", dt, len(pending_vids))
  348. _poll_decode_results_for_today(dt, pending_vids)
  349. else:
  350. logger.warning(
  351. "存在非终态记录但未获取到可查询vid dt={} count={}",
  352. dt,
  353. initial_non_terminal,
  354. )
  355. remaining_non_terminal = _count_today_non_terminal(dt)
  356. if remaining_non_terminal > 0:
  357. logger.info(
  358. "查询后仍有待执行/执行中任务,跳过新批次发起 dt={} count={}",
  359. dt,
  360. remaining_non_terminal,
  361. )
  362. logger.info("解码调度任务结束(启动保护:仅查询)")
  363. return
  364. else:
  365. logger.info(
  366. "查询后当天进行中任务已清空,立即发起新批次 dt={}",
  367. dt,
  368. )
  369. # fallthrough: submit new batch in the same run
  370. if not _is_decode_submit_open():
  371. logger.info("解构开关关闭(is_open!=1),跳过本轮新批次发起 dt={}", dt)
  372. logger.info("解码调度任务结束(开关关闭:不发起新任务)")
  373. return
  374. records = _pick_candidate_records(dt=dt, batch_size=BATCH_SIZE)
  375. if not records:
  376. logger.info("无可发起的新批次候选数据 dt={}", dt)
  377. logger.info("解码调度任务结束(无新增任务)")
  378. return
  379. logger.info("解码提交接口执行开始 records={}", records)
  380. posts = _build_posts_payload(records)
  381. logger.info("解码提交接口执行开始 posts={}", posts)
  382. ok, err_msg, body = _submit_decode(posts)
  383. logger.info(
  384. "解码提交接口执行完成 success={} records={} msg={} body={}",
  385. ok,
  386. len(records),
  387. err_msg,
  388. body,
  389. )
  390. if not ok:
  391. fail_body = json.dumps({"decode_submit_response": body}, ensure_ascii=False)
  392. for item in records:
  393. _insert_task_result_row(
  394. item,
  395. status=3,
  396. err_msg=err_msg or "解码提交失败",
  397. data_content=fail_body,
  398. html=None,
  399. )
  400. else:
  401. if isinstance(body.get("data"), list) and body["data"]:
  402. _insert_rows_after_decode_submit(records, body)
  403. else:
  404. payload = json.dumps({"decode_submit_response": body}, ensure_ascii=False)
  405. for item in records:
  406. _insert_task_result_row(
  407. item,
  408. status=1,
  409. err_msg="",
  410. data_content=payload,
  411. html=None,
  412. )
  413. logger.info("解码调度任务结束,本轮新发起数量={}", len(records))
  414. except Exception as exc:
  415. logger.exception("解码调度任务异常退出: {}", exc)
  416. return