"""15 天不活跃清零的进程内每日任务。 仿 daily_exchange_worker:App 启动自带,每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次, 跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 点)后跑一轮 `run_once`(预警 + 清零)。 健壮性: - **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。 - **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。 - **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单、 不动钱(dry-run 灰度看名单),=true 才真清。 ⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见 app.repositories.inactivity / app.repositories.activity。 """ from __future__ import annotations import asyncio import contextlib import logging import os import time from collections.abc import Iterator from datetime import date, datetime from pathlib import Path from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.core.rewards import CN_TZ, cn_today from app.db.session import SessionLocal from app.integrations.notifier import get_notifier from app.repositories import inactivity as inactivity_repo logger = logging.getLogger("shagua.inactivity") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock" def _cn_today() -> date: return cn_today() 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 _run_once_entry() -> dict: """跑一轮(预警 + 清零)。独立开 Session。""" notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL) with SessionLocal() as db: return inactivity_repo.run_once( db, notifier=notifier, reset_days=settings.INACTIVITY_RESET_DAYS, warn_stages=settings.inactivity_warn_stages, today=_cn_today(), dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清 ) async def _run_loop() -> None: interval = max(60, int(settings.INACTIVITY_RESET_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("inactivity reset skipped: another worker owns lock") return await _run_locked_loop(interval) async def _run_locked_loop(interval: int) -> None: logger.info( "inactivity reset worker started interval=%ss run_hour=%s mode=%s", interval, settings.INACTIVITY_RESET_RUN_HOUR, "clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)", ) # 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。 last_run: date | None = None try: while True: try: _touch_lock() today = _cn_today() hour = datetime.now(CN_TZ).hour if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR): result = await asyncio.to_thread(_run_once_entry) last_run = today logger.info("inactivity reset done date=%s result=%s", today, result) except SQLAlchemyError: logger.exception("inactivity reset db error") except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 logger.exception("inactivity reset unexpected error") await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("inactivity reset worker stopped") raise def start_inactivity_reset_worker() -> asyncio.Task | None: # worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**: # false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。 return asyncio.create_task(_run_loop(), name="inactivity-reset") async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None: if task is None: return task.cancel() with contextlib.suppress(asyncio.CancelledError): await task