sheet_approval.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. """Poll editable ROI sheets and execute approved database action items."""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import threading
  6. from datetime import datetime
  7. from typing import Any
  8. from zoneinfo import ZoneInfo
  9. import httpx
  10. from openpyxl.utils import get_column_letter
  11. from .config import RoiConfig
  12. from .execution import execute_approved_roi_actions
  13. from .feishu import BASE_URL, RoiFeishuPublisher
  14. from .repository import (
  15. expire_pending_sheet_runs,
  16. finalize_sheet_run_if_resolved,
  17. load_actions_by_ids,
  18. load_pending_sheet_runs,
  19. load_unnotified_action_details,
  20. mark_action_notifications,
  21. record_sheet_decision,
  22. )
  23. SHANGHAI = ZoneInfo("Asia/Shanghai")
  24. APPROVAL_SHEET_NAME = "小程序投流"
  25. APPROVED_VALUES = {"批准", "approve", "approved"}
  26. REJECTED_VALUES = {"拒绝", "reject", "rejected"}
  27. logger = logging.getLogger("auto_put_ad_mini.roi_sheet_approval")
  28. def parse_approval_rows(values: list[list[Any]]) -> list[dict[str, Any]]:
  29. if not values:
  30. return []
  31. headers = {
  32. str(value or "").strip(): index
  33. for index, value in enumerate(values[0])
  34. if str(value or "").strip()
  35. }
  36. required = {"审批选择", "动作幂等键"}
  37. missing = required - headers.keys()
  38. if missing:
  39. raise RuntimeError(f"ROI审批表缺少列: {', '.join(sorted(missing))}")
  40. decisions: list[dict[str, Any]] = []
  41. for row_number, row in enumerate(values[1:], start=2):
  42. approval_index = headers["审批选择"]
  43. key_index = headers["动作幂等键"]
  44. approval = str(row[approval_index] if approval_index < len(row) else "").strip()
  45. key = str(row[key_index] if key_index < len(row) else "").strip()
  46. normalized = approval.lower()
  47. if not key:
  48. continue
  49. if normalized in APPROVED_VALUES:
  50. decision = "APPROVED"
  51. elif normalized in REJECTED_VALUES:
  52. decision = "REJECTED"
  53. else:
  54. continue
  55. decisions.append(
  56. {
  57. "row_number": row_number,
  58. "idempotency_key": key,
  59. "decision": decision,
  60. "headers": headers,
  61. }
  62. )
  63. return decisions
  64. class RoiSheetClient:
  65. def __init__(self, timeout: float = 30.0) -> None:
  66. self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
  67. self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  68. if not self.app_id or not self.app_secret:
  69. raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
  70. self.client = httpx.Client(timeout=timeout)
  71. def close(self) -> None:
  72. self.client.close()
  73. @staticmethod
  74. def _json(response: httpx.Response, action: str) -> dict[str, Any]:
  75. response.raise_for_status()
  76. payload = response.json()
  77. if payload.get("code") != 0:
  78. raise RuntimeError(f"{action} failed: {payload.get('msg', payload)}")
  79. return payload
  80. def _token(self) -> str:
  81. response = self.client.post(
  82. f"{BASE_URL}/auth/v3/tenant_access_token/internal",
  83. json={"app_id": self.app_id, "app_secret": self.app_secret},
  84. )
  85. return self._json(response, "get tenant token")["tenant_access_token"]
  86. @staticmethod
  87. def _headers(token: str) -> dict[str, str]:
  88. return {"Authorization": f"Bearer {token}"}
  89. def _sheet_id(self, token: str, sheet_token: str) -> str:
  90. response = self.client.get(
  91. f"{BASE_URL}/sheets/v3/spreadsheets/{sheet_token}/sheets/query",
  92. headers=self._headers(token),
  93. )
  94. sheets = (
  95. self._json(response, "query ROI sheets")
  96. .get("data", {})
  97. .get("sheets", [])
  98. )
  99. target = next(
  100. (sheet for sheet in sheets if sheet.get("title") == APPROVAL_SHEET_NAME),
  101. None,
  102. )
  103. if not target:
  104. raise RuntimeError(f"ROI审批表缺少工作表: {APPROVAL_SHEET_NAME}")
  105. return str(target["sheet_id"])
  106. def _read_values(
  107. self,
  108. token: str,
  109. sheet_token: str,
  110. cell_range: str,
  111. ) -> list[list[Any]]:
  112. response = self.client.get(
  113. f"{BASE_URL}/sheets/v2/spreadsheets/{sheet_token}/values/{cell_range}",
  114. headers=self._headers(token),
  115. params={"valueRenderOption": "ToString"},
  116. )
  117. return (
  118. self._json(response, "read ROI approvals")
  119. .get("data", {})
  120. .get("valueRange", {})
  121. .get("values", [])
  122. ) or []
  123. def read_approvals(self, sheet_token: str) -> tuple[str, list[dict[str, Any]]]:
  124. token = self._token()
  125. sheet_id = self._sheet_id(token, sheet_token)
  126. header_rows = self._read_values(
  127. token,
  128. sheet_token,
  129. f"{sheet_id}!A1:ZZ1",
  130. )
  131. if not header_rows:
  132. return sheet_id, []
  133. headers = {
  134. str(value or "").strip(): index
  135. for index, value in enumerate(header_rows[0])
  136. if str(value or "").strip()
  137. }
  138. required = {"审批选择", "动作幂等键"}
  139. missing = required - headers.keys()
  140. if missing:
  141. raise RuntimeError(f"ROI审批表缺少列: {', '.join(sorted(missing))}")
  142. approval_letter = get_column_letter(headers["审批选择"] + 1)
  143. key_letter = get_column_letter(headers["动作幂等键"] + 1)
  144. approval_rows = self._read_values(
  145. token,
  146. sheet_token,
  147. f"{sheet_id}!{approval_letter}2:{approval_letter}5000",
  148. )
  149. key_rows = self._read_values(
  150. token,
  151. sheet_token,
  152. f"{sheet_id}!{key_letter}2:{key_letter}5000",
  153. )
  154. decisions: list[dict[str, Any]] = []
  155. for offset in range(max(len(approval_rows), len(key_rows))):
  156. approval_row = approval_rows[offset] if offset < len(approval_rows) else []
  157. key_row = key_rows[offset] if offset < len(key_rows) else []
  158. approval = str(approval_row[0] if approval_row else "").strip().lower()
  159. key = str(key_row[0] if key_row else "").strip()
  160. if not key:
  161. continue
  162. if approval in APPROVED_VALUES:
  163. decision = "APPROVED"
  164. elif approval in REJECTED_VALUES:
  165. decision = "REJECTED"
  166. else:
  167. continue
  168. decisions.append(
  169. {
  170. "row_number": offset + 2,
  171. "idempotency_key": key,
  172. "decision": decision,
  173. "headers": headers,
  174. }
  175. )
  176. return sheet_id, decisions
  177. def write_results(
  178. self,
  179. sheet_token: str,
  180. sheet_id: str,
  181. decisions: list[dict[str, Any]],
  182. actions: list[dict[str, Any]],
  183. ) -> None:
  184. by_id = {int(item["id"]): item for item in actions}
  185. token = self._token()
  186. value_ranges = []
  187. for decision in decisions:
  188. item = by_id.get(int(decision["item_id"]))
  189. if not item:
  190. continue
  191. headers = decision["headers"]
  192. status_column = headers.get("执行状态")
  193. result_column = headers.get("执行结果")
  194. if status_column is None or result_column is None:
  195. continue
  196. execution_status = str(item.get("execution_status") or "")
  197. status = {
  198. "SUCCESS": "执行成功",
  199. "REJECTED": "已拒绝",
  200. "PENDING": "待执行",
  201. "PREPARED": "执行中",
  202. }.get(execution_status, "执行失败" if execution_status else "")
  203. result = str(item.get("error_message") or item.get("skip_reason") or "")
  204. start = get_column_letter(status_column + 1)
  205. end = get_column_letter(result_column + 1)
  206. value_ranges.append(
  207. {
  208. "range": f"{sheet_id}!{start}{decision['row_number']}:{end}{decision['row_number']}",
  209. "values": [[status, result]],
  210. }
  211. )
  212. if not value_ranges:
  213. return
  214. response = self.client.post(
  215. f"{BASE_URL}/sheets/v2/spreadsheets/"
  216. f"{sheet_token}/values_batch_update",
  217. headers={**self._headers(token), "Content-Type": "application/json"},
  218. json={"valueRanges": value_ranges},
  219. )
  220. self._json(response, "write ROI execution results")
  221. class RoiSheetApprovalService:
  222. def __init__(self) -> None:
  223. self.config = RoiConfig.from_env()
  224. self.lock_name = os.getenv("RTC_DB_LOCK_NAME", "tencent_realtime_control")
  225. self.client = RoiSheetClient()
  226. self.stop_event = threading.Event()
  227. def close(self) -> None:
  228. self.stop_event.set()
  229. self.client.close()
  230. def process_once(self, now: datetime | None = None) -> dict[str, int]:
  231. current = now or datetime.now(SHANGHAI)
  232. expired = expire_pending_sheet_runs(current)
  233. run_count = 0
  234. decision_count = 0
  235. failed_runs = 0
  236. for run in load_pending_sheet_runs(current):
  237. run_count += 1
  238. try:
  239. decision_count += self._process_run(run, current)
  240. except Exception:
  241. failed_runs += 1
  242. logger.exception(
  243. "Skip incompatible or unavailable ROI sheet run=%s",
  244. run["run_id"],
  245. )
  246. self._notify_results(current)
  247. return {
  248. "runs": run_count,
  249. "decisions": decision_count,
  250. "expired": expired,
  251. "failed_runs": failed_runs,
  252. }
  253. def _process_run(self, run: dict[str, Any], now: datetime) -> int:
  254. sheet_id, sheet_decisions = self.client.read_approvals(run["sheet_token"])
  255. approved_ids: list[int] = []
  256. processed_decisions: list[dict[str, Any]] = []
  257. changed_count = 0
  258. for decision in sheet_decisions:
  259. item = record_sheet_decision(
  260. run_id=run["run_id"],
  261. idempotency_key=decision["idempotency_key"],
  262. decision=decision["decision"],
  263. sheet_row_number=decision["row_number"],
  264. now=now,
  265. )
  266. if not item:
  267. continue
  268. changed_count += int(bool(item.get("decision_changed")))
  269. decision["item_id"] = int(item["id"])
  270. processed_decisions.append(decision)
  271. if item.get("approval_status") == "APPROVED":
  272. approved_ids.append(int(item["id"]))
  273. if approved_ids:
  274. execute_approved_roi_actions(
  275. approved_ids,
  276. now=now,
  277. lock_name=self.lock_name,
  278. )
  279. finalize_sheet_run_if_resolved(run["run_id"], now=now)
  280. if processed_decisions:
  281. actions = load_actions_by_ids(
  282. [decision["item_id"] for decision in processed_decisions]
  283. )
  284. try:
  285. self.client.write_results(
  286. run["sheet_token"],
  287. sheet_id,
  288. processed_decisions,
  289. actions,
  290. )
  291. except Exception:
  292. logger.exception("Failed to write ROI execution result back to sheet")
  293. return changed_count
  294. def _notify_results(self, now: datetime) -> None:
  295. rows = load_unnotified_action_details()
  296. if not rows:
  297. return
  298. item_ids = [int(row["id"]) for row in rows]
  299. publisher = RoiFeishuPublisher()
  300. try:
  301. publisher.send_execution_results(rows)
  302. except Exception as exc:
  303. mark_action_notifications(item_ids, notified_at=None, error=str(exc))
  304. raise
  305. finally:
  306. publisher.close()
  307. mark_action_notifications(item_ids, notified_at=now, error=None)
  308. def run_forever(self) -> None:
  309. logger.info(
  310. "ROI sheet approval poller started interval=%ss",
  311. self.config.sheet_approval_poll_seconds,
  312. )
  313. while not self.stop_event.is_set():
  314. try:
  315. result = self.process_once()
  316. if result["runs"] or result["expired"]:
  317. logger.info("ROI sheet approval cycle result=%s", result)
  318. except Exception:
  319. logger.exception("ROI sheet approval cycle failed")
  320. self.stop_event.wait(self.config.sheet_approval_poll_seconds)
  321. def start(self) -> threading.Thread:
  322. thread = threading.Thread(
  323. target=self.run_forever,
  324. name="roi-sheet-approval",
  325. daemon=True,
  326. )
  327. thread.start()
  328. return thread