"""提现 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 app_config from app.repositories import wallet as wallet_repo _AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled" 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 | None: """读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。 env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起); 这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。 """ with SessionLocal() as db: if not app_config.get_value(db, _AUTO_RECONCILE_KEY): return None 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 worker started interval=%ss older_than=%sm " "(runtime on/off via app_config '%s')", interval, older_than, _AUTO_RECONCILE_KEY, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) if result is not None and (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: # 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。 logger.info("withdraw auto reconcile worker not started (env master switch off)") 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