"""0 点金币→现金自动兑换的进程内定时任务。 替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,每 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮 `wallet.daily_auto_exchange`。 健壮性: - **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on), 故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。 - **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。 - **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。 - **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。 """ from __future__ import annotations import asyncio import contextlib import logging import os import time from collections.abc import Iterator from datetime import date from pathlib import Path from sqlalchemy.exc import SQLAlchemyError from app.core import rewards from app.core.config import settings from app.db.session import SessionLocal from app.repositories import wallet as wallet_repo logger = logging.getLogger("shagua.daily_exchange") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.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 _exchange_once() -> dict: with SessionLocal() as db: return wallet_repo.daily_auto_exchange(db) async def _run_loop() -> None: interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC)) lock_stale_after = max(interval * 3, 1800) with _single_instance_lock(lock_stale_after) as lock_acquired: if not lock_acquired: logger.warning("daily auto-exchange skipped: another worker owns lock") return await _run_locked_loop(interval) async def _run_locked_loop(interval: int) -> None: logger.info("daily auto-exchange worker started interval=%ss", interval) # 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。 last_run: date | None = None try: while True: try: _touch_lock() today = rewards.cn_today() if last_run != today: result = await asyncio.to_thread(_exchange_once) last_run = today logger.info("daily auto-exchange done date=%s result=%s", today, result) except SQLAlchemyError: logger.exception("daily auto-exchange db error") except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 logger.exception("daily auto-exchange unexpected error") await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("daily auto-exchange worker stopped") raise def start_daily_exchange_worker() -> asyncio.Task | None: if not settings.AUTO_EXCHANGE_ENABLED: logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)") return None return asyncio.create_task(_run_loop(), name="daily-auto-exchange") async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None: if task is None: return task.cancel() with contextlib.suppress(asyncio.CancelledError): await task