"""提现 pending 单自动对账后台任务。""" from __future__ import annotations import asyncio import contextlib import logging import os import time from collections.abc import Iterator from pathlib import Path from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal from app.integrations.wxpay import WxPayNotConfiguredError from app.repositories import wallet as wallet_repo logger = logging.getLogger("shagua.withdraw_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock" def _touch_lock() -> None: with contextlib.suppress(FileNotFoundError): os.utime(_LOCK_PATH, None) @contextlib.contextmanager def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: """同机多进程保护:同一时间只允许一个自动查单 worker 运行。""" _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) fd: int | None = None try: try: fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) except FileExistsError: try: age = time.time() - _LOCK_PATH.stat().st_mtime except FileNotFoundError: age = stale_after_sec + 1 if age > stale_after_sec: with contextlib.suppress(FileNotFoundError): _LOCK_PATH.unlink() try: fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) except FileExistsError: fd = None if fd is None: yield False return os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii")) yield True finally: if fd is not None: os.close(fd) with contextlib.suppress(FileNotFoundError): _LOCK_PATH.unlink() def _reconcile_once(older_than_minutes: int) -> dict: with SessionLocal() as db: return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) async def _run_loop() -> None: interval = max(30, int(settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC)) older_than = max(1, int(settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES)) lock_stale_after = max(interval * 3, 600) with _single_instance_lock(lock_stale_after) as lock_acquired: if not lock_acquired: logger.warning("withdraw auto reconcile skipped: another worker owns lock") return await _run_locked_loop(interval, older_than) async def _run_locked_loop(interval: int, older_than: int) -> None: logger.info( "withdraw auto reconcile started interval=%ss older_than=%sm", interval, older_than, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) if result["checked"] or result["resolved"]: logger.info("withdraw auto reconcile result=%s", result) except WxPayNotConfiguredError: logger.warning("withdraw auto reconcile skipped: wxpay not configured") except SQLAlchemyError: logger.exception("withdraw auto reconcile db error") except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 logger.exception("withdraw auto reconcile unexpected error") await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("withdraw auto reconcile stopped") raise def start_withdraw_reconcile_worker() -> asyncio.Task | None: if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: logger.info("withdraw auto reconcile disabled") return None if not settings.wxpay_configured: logger.warning("withdraw auto reconcile enabled but wxpay not configured") return asyncio.create_task(_run_loop(), name="withdraw-auto-reconcile") async def stop_withdraw_reconcile_worker(task: asyncio.Task | None) -> None: if task is None: return task.cancel() with contextlib.suppress(asyncio.CancelledError): await task