diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index 8252df1f..06fe9297 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -104,6 +104,9 @@ POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 # 支付自动补单 POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true +POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20 +POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300 +POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3 ``` ## 5. 验证步骤 diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index fa9e6ee5..94010b71 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -229,9 +229,19 @@ class StartupCoordinator: def _start_payment_confirm_loop(self) -> LoopStatus: enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True) + interval_sec = max( + 5, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20) + ) + idle_interval_sec = max( + interval_sec, + _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC", 300), + ) details = { - "interval_sec": max( - 5, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20) + "interval_sec": interval_sec, + "idle_interval_sec": idle_interval_sec, + "idle_after_empty_cycles": max( + 1, + _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES", 3), ), "batch_size": max( 1, min(200, _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_BATCH_SIZE", 20)) diff --git a/src/payments/confirm_loop.py b/src/payments/confirm_loop.py index 81e7e4b6..1d727e4d 100644 --- a/src/payments/confirm_loop.py +++ b/src/payments/confirm_loop.py @@ -67,12 +67,28 @@ def _runner() -> None: return interval_sec = _env_int("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", 20, 5) + idle_interval_sec = _env_int( + "POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC", + 300, + interval_sec, + ) + idle_interval_sec = max(interval_sec, idle_interval_sec) + idle_after_empty_cycles = _env_int( + "POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES", + 3, + 1, + ) 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={}", + ( + "payment confirm loop started active_interval={}s idle_interval={}s " + "idle_after_empty_cycles={} batch={} chain_id={} confirmations={}" + ), interval_sec, + idle_interval_sec, + idle_after_empty_cycles, batch_size, PAYMENT_CHECKOUT.chain_id, PAYMENT_CHECKOUT.confirmations, @@ -81,13 +97,17 @@ def _runner() -> None: "confirm_loop_started", { "interval_sec": interval_sec, + "idle_interval_sec": idle_interval_sec, + "idle_after_empty_cycles": idle_after_empty_cycles, "batch_size": batch_size, "chain_id": PAYMENT_CHECKOUT.chain_id, "confirmations": PAYMENT_CHECKOUT.confirmations, }, ) + empty_cycles = 0 while True: + sleep_sec = interval_sec try: intents = PAYMENT_CHECKOUT.list_pending_confirm_intents(limit=batch_size) scanned = len(intents) @@ -149,10 +169,17 @@ def _runner() -> None: failed, ) _append_audit_event("confirm_loop_cycle", cycle_summary) + if scanned: + empty_cycles = 0 + else: + empty_cycles += 1 + if empty_cycles >= idle_after_empty_cycles: + sleep_sec = idle_interval_sec except Exception as exc: + empty_cycles = 0 logger.warning(f"payment confirm cycle failed: {exc}") _append_audit_event("confirm_loop_error", {"error": str(exc)}) - time.sleep(interval_sec) + time.sleep(sleep_sec) def start_payment_confirm_loop(): diff --git a/tests/test_payment_confirm_loop.py b/tests/test_payment_confirm_loop.py new file mode 100644 index 00000000..9cf96f85 --- /dev/null +++ b/tests/test_payment_confirm_loop.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest + +from src.payments import confirm_loop + + +class _StopLoop(Exception): + pass + + +class _FakeCheckout: + enabled = True + chain_id = 137 + confirmations = 2 + + def __init__(self, batches): + self._batches = list(batches) + self.confirmed = [] + + def list_pending_confirm_intents(self, limit): + if not self._batches: + return [] + return self._batches.pop(0) + + def confirm_intent_tx(self, *, user_id, intent_id, tx_hash): + self.confirmed.append( + {"user_id": user_id, "intent_id": intent_id, "tx_hash": tx_hash} + ) + return {"already_confirmed": True} + + +def _run_until_sleep_count(monkeypatch, checkout, sleep_count): + sleeps = [] + + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", "true") + + def fake_sleep(seconds): + sleeps.append(seconds) + if len(sleeps) >= sleep_count: + raise _StopLoop + + monkeypatch.setattr(confirm_loop, "PAYMENT_CHECKOUT", checkout) + monkeypatch.setattr(confirm_loop, "_append_audit_event", lambda *_args: None) + monkeypatch.setattr(confirm_loop.time, "sleep", fake_sleep) + + with pytest.raises(_StopLoop): + confirm_loop._runner() + + return sleeps + + +def test_confirm_loop_uses_idle_interval_after_repeated_empty_cycles(monkeypatch): + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", "20") + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC", "300") + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES", "2") + + sleeps = _run_until_sleep_count( + monkeypatch, + _FakeCheckout(batches=[[], []]), + sleep_count=2, + ) + + assert sleeps == [20, 300] + + +def test_confirm_loop_returns_to_active_interval_when_intent_is_found(monkeypatch): + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC", "20") + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC", "300") + monkeypatch.setenv("POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES", "1") + checkout = _FakeCheckout( + batches=[ + [], + [ + { + "intent_id": "intent-1", + "user_id": "user-1", + "tx_hash": "0x" + "a" * 64, + } + ], + ] + ) + + sleeps = _run_until_sleep_count(monkeypatch, checkout, sleep_count=2) + + assert sleeps == [300, 20] + assert checkout.confirmed == [ + { + "intent_id": "intent-1", + "user_id": "user-1", + "tx_hash": "0x" + "a" * 64, + } + ]