feat: Introduce a runtime coordinator for bot loops and add new payment checkout and confirmation modules.
This commit is contained in:
@@ -1092,7 +1092,7 @@ def _build_telegram_messages_mispricing(
|
||||
if anchor_high_c is not None and anchor_settle is not None:
|
||||
if anchor_model:
|
||||
lines_zh.append(
|
||||
f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}C(结算参考 {anchor_settle}C)"
|
||||
f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}°C (结算参考 {anchor_settle}°C )"
|
||||
)
|
||||
else:
|
||||
lines_zh.append(
|
||||
|
||||
@@ -87,6 +87,7 @@ class StartupCoordinator:
|
||||
self._start_polygon_wallet_loop(),
|
||||
self._start_polymarket_wallet_activity_loop(),
|
||||
self._start_weekly_reward_loop(),
|
||||
self._start_payment_confirm_loop(),
|
||||
]
|
||||
self._runtime_status = RuntimeStatus(
|
||||
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
@@ -272,6 +273,33 @@ class StartupCoordinator:
|
||||
),
|
||||
)
|
||||
|
||||
def _start_payment_confirm_loop(self) -> LoopStatus:
|
||||
enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True)
|
||||
details = {
|
||||
"interval_sec": max(
|
||||
5, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20)
|
||||
),
|
||||
"batch_size": max(
|
||||
1, min(200, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_BATCH_SIZE", 20))
|
||||
),
|
||||
"payment_enabled": _env_bool("POLYWEATHER_PAYMENT_ENABLED", False),
|
||||
"chain_id": _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", 137),
|
||||
"confirmations": max(1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2)),
|
||||
}
|
||||
validation_error = None
|
||||
if not bool(details["payment_enabled"]):
|
||||
validation_error = "payment_service_disabled"
|
||||
return self._start_with_validation(
|
||||
key="payment_confirm",
|
||||
label="支付自动补单",
|
||||
configured_enabled=enabled,
|
||||
details=details,
|
||||
validation_error=validation_error,
|
||||
starter=lambda: import_module(
|
||||
"src.payments.confirm_loop"
|
||||
).start_payment_confirm_loop(),
|
||||
)
|
||||
|
||||
|
||||
def render_runtime_status_html(status: RuntimeStatus) -> str:
|
||||
lines = [
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _env_int(name: str, default: int, min_value: int = 0) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except Exception:
|
||||
return default
|
||||
return max(min_value, value)
|
||||
|
||||
|
||||
def _is_pending_confirm_error(exc: PaymentCheckoutError) -> bool:
|
||||
detail = str(exc.detail or "").lower()
|
||||
if exc.status_code in {404, 408}:
|
||||
return True
|
||||
if exc.status_code == 409 and (
|
||||
"confirmations not enough" in detail or "tx indexed partially" in detail
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _short_hash(tx_hash: str) -> str:
|
||||
text = str(tx_hash or "")
|
||||
if len(text) < 18:
|
||||
return text
|
||||
return f"{text[:10]}...{text[-6:]}"
|
||||
|
||||
|
||||
def _runner() -> None:
|
||||
enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True)
|
||||
if not enabled:
|
||||
logger.info("payment confirm loop disabled")
|
||||
return
|
||||
|
||||
if not PAYMENT_CHECKOUT.enabled:
|
||||
logger.info("payment confirm loop skipped: payment service disabled")
|
||||
return
|
||||
|
||||
interval_sec = _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20, 5)
|
||||
batch_size = _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_BATCH_SIZE", 20, 1)
|
||||
batch_size = min(batch_size, 200)
|
||||
|
||||
logger.info(
|
||||
"payment confirm loop started interval={}s batch={} chain_id={} confirmations={}",
|
||||
interval_sec,
|
||||
batch_size,
|
||||
PAYMENT_CHECKOUT.chain_id,
|
||||
PAYMENT_CHECKOUT.confirmations,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
intents = PAYMENT_CHECKOUT.list_pending_confirm_intents(limit=batch_size)
|
||||
scanned = len(intents)
|
||||
confirmed = 0
|
||||
already_confirmed = 0
|
||||
pending = 0
|
||||
failed = 0
|
||||
|
||||
for row in intents:
|
||||
intent_id = str(row.get("intent_id") or "").strip()
|
||||
user_id = str(row.get("user_id") or "").strip()
|
||||
tx_hash = str(row.get("tx_hash") or "").strip().lower()
|
||||
if not intent_id or not user_id or not tx_hash:
|
||||
continue
|
||||
try:
|
||||
result: Dict[str, Any] = PAYMENT_CHECKOUT.confirm_intent_tx(
|
||||
user_id=user_id,
|
||||
intent_id=intent_id,
|
||||
tx_hash=tx_hash,
|
||||
)
|
||||
if bool(result.get("already_confirmed")):
|
||||
already_confirmed += 1
|
||||
else:
|
||||
confirmed += 1
|
||||
logger.info(
|
||||
"payment auto-confirmed intent={} user={} tx={}",
|
||||
intent_id,
|
||||
user_id,
|
||||
_short_hash(tx_hash),
|
||||
)
|
||||
except PaymentCheckoutError as exc:
|
||||
if _is_pending_confirm_error(exc):
|
||||
pending += 1
|
||||
continue
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"payment auto-confirm failed intent={} user={} tx={} status={} detail={}",
|
||||
intent_id,
|
||||
user_id,
|
||||
_short_hash(tx_hash),
|
||||
exc.status_code,
|
||||
exc.detail,
|
||||
)
|
||||
|
||||
if scanned and (confirmed or already_confirmed or failed):
|
||||
logger.info(
|
||||
"payment confirm cycle scanned={} confirmed={} already={} pending={} failed={}",
|
||||
scanned,
|
||||
confirmed,
|
||||
already_confirmed,
|
||||
pending,
|
||||
failed,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"payment confirm cycle failed: {exc}")
|
||||
time.sleep(interval_sec)
|
||||
|
||||
|
||||
def start_payment_confirm_loop():
|
||||
thread = threading.Thread(
|
||||
target=_runner,
|
||||
daemon=True,
|
||||
name="payment-confirm-loop",
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
@@ -1278,6 +1278,49 @@ class PaymentContractCheckoutService:
|
||||
raise PaymentCheckoutError(404, "payment intent not found")
|
||||
return self._serialize_intent(rows[0])
|
||||
|
||||
def list_pending_confirm_intents(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List submitted intents that already have tx_hash and need background confirm.
|
||||
"""
|
||||
self._ensure_enabled()
|
||||
safe_limit = max(1, min(int(limit or 20), 200))
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id",
|
||||
"status": "eq.submitted",
|
||||
"tx_hash": "not.is.null",
|
||||
"chain_id": f"eq.{self.chain_id}",
|
||||
"order": "updated_at.asc",
|
||||
"limit": str(safe_limit),
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
intent_id = str(row.get("id") or "").strip()
|
||||
user_id = str(row.get("user_id") or "").strip()
|
||||
tx_hash = str(row.get("tx_hash") or "").strip().lower()
|
||||
if not intent_id or not user_id or not tx_hash:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"intent_id": intent_id,
|
||||
"user_id": user_id,
|
||||
"tx_hash": tx_hash,
|
||||
"status": str(row.get("status") or ""),
|
||||
"updated_at": row.get("updated_at"),
|
||||
"created_at": row.get("created_at"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def submit_intent_tx(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
Reference in New Issue
Block a user