Files
shaguabijia-app-server/app/core/inactive_clear_worker.py
T
no_gen_mu f7fc4af17c feat(wallet): 连续15天无活跃账户金币+现金自动清零
新增进程内定时任务:每天北京0点后扫描,对连续无活跃满15整天(既没发起比价、
也没发起领券)的用户,清空金币与金币兑换现金;单纯登录不算活跃,邀请奖励金物理
隔离不动,每笔清零写 inactive_clear 流水可逐笔回溯。

- 活跃口径=发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started
  (登录 last_login_at 不算);新用户靠 created_at 兜底(注册未满15天豁免,不会刚注册就被清)
- worker 照 daily_exchange_worker 结构:文件锁同机互斥 + 北京日切 + 启动补跑
- 只扫有余额账户,清零走 grant_coins/grant_cash 负数入账 → 天然幂等
- 循环内读实时余额 + 行锁清零防并发;days<1 钳位防误配全员清空
- 新增 INACTIVE_CLEAR_ENABLED/DAYS/CHECK_INTERVAL_SEC 三开关(默认开,15天)
- 复用现有表无需迁移

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:45:20 +08:00

133 lines
4.6 KiB
Python

"""连续 N 天无活跃账户金币 / 现金清零的进程内定时任务。
结构同 app.core.daily_exchange_worker:每 `INACTIVE_CLEAR_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天(0 点后)就跑一轮 inactive_clear.clear_inactive_accounts。
健壮性:
- **天然幂等**:只清有余额的账户,清完余额=0,下一轮 / 重启 / 多次唤醒都不重复清。
- **当天首跑即补**:进程起来时若当天还没跑过,立即跑一轮。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
- **开关**:settings.INACTIVE_CLEAR_ENABLED=false 时不启动。
⚠️ 这是不可逆的批量资金操作(清空用户金币 + 金币现金,邀请金除外)。活跃口径与清零
细节见 app.repositories.inactive_clear。
"""
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 inactive_clear as inactive_clear_repo
logger = logging.getLogger("shagua.inactive_clear")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactive_clear.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 _clear_once() -> dict:
with SessionLocal() as db:
return inactive_clear_repo.clear_inactive_accounts(
db, days=settings.INACTIVE_CLEAR_DAYS
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVE_CLEAR_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("inactive-clear skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactive-clear worker started interval=%ss days=%s",
interval,
settings.INACTIVE_CLEAR_DAYS,
)
# 本进程上次跑过的北京日;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(_clear_once)
last_run = today
logger.info("inactive-clear done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactive-clear db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactive-clear unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactive-clear worker stopped")
raise
def start_inactive_clear_worker() -> asyncio.Task | None:
if not settings.INACTIVE_CLEAR_ENABLED:
logger.info("inactive-clear disabled (INACTIVE_CLEAR_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="inactive-clear")
async def stop_inactive_clear_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task