a86688ccfb
背景 / 目标 连续 15 天(北京自然日)没有「首页可见 / 发起比价 / 发起领券」行为的用户判定为流失,每日自动清零其金币 + 折算现金;清零前按可配置节奏预警;全程留审计。纯登录不算活跃;邀请奖励金物理隔离、不清(产品红线)。 改了什么 活跃口径共享模块 app/repositories/activity.py:worker(清零/预警)与 admin(最近活跃)单一真源,防漂移。 清零业务逻辑 inactivity.py:选取 / 逐用户清零(行锁 + 幂等)/ 预警分档 + streak 去重 / run_once 组合,预警故障逐用户隔离、绝不阻塞清零。 每日 worker inactivity_reset_worker.py(仿 daily_exchange:文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸)+ main.py lifespan 接线。 可插拔通知器 notifier.py(v1 LogNotifier 日志占位,预留 JPush/短信)。 配置 INACTIVITY_*(阈值 / 预警档 / 执行点 / 通道 / 开关)。 admin 最近活跃口径改用共享模块(移除 last_login_at、纳入 show/home、以 created_at 为基线)。 审计:inactivity_reset_log + inactivity_notification_log 两表 + 钱包流水双写(biz_type=inactivity_reset,ref_id 交叉)。 文档:设计 spec / 实现 plan / docs/database/ 两表字典。 关键产品决策 邀请金不清:只清金币 + 折算现金,invite_cash_balance_cents 原封(仅快照入审计)。 活跃口径 = max(created_at, 首页可见, 比价, 领券),不含 last_login_at;首页可见 = event=show + page=home。 时间边界:北京自然日 0 点对齐(见 activity.reset_cutoff)。 总闸默认关,灰度验证后再开。 数据库变更 新表:inactivity_reset_log、inactivity_notification_log。 analytics_event 新增复合覆盖索引 ix_analytics_event_active (event, page, user_id, created_at)(活跃口径聚合热点)。 修复了 base 上的迁移多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉)→ 加空 merge 修订,alembic upgrade head 恢复单头正常。 ⚠️ 上线注意(合并后 / 开总闸前) INACTIVITY_RESET_ENABLED 默认 false;开启前提 = show/home 埋点全量铺满——否则"只登录不操作"且注册满 15 天的老用户会落到 created_at 基线被误清。 前端依赖:Android 端需在首页可见上报 event=show + page=home(携带登录后的 user_id)。 admin「最近活跃」口径变化(去登录、纳入 home_view、created_at 基线):属预期变化,需产品/运营知会;与 DAU(_period_active_user_ids,仍含登录)是两套指标。 清零对 C 端「金币流水」可见(biz_type=inactivity_reset,备注「15天不活跃清零」)。 灰度:先只看预警/清零名单对不对,再开总闸。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #143
144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
"""15 天不活跃清零的进程内每日任务。
|
|
|
|
仿 daily_exchange_worker:App 启动自带,每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次,
|
|
跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 点)后跑一轮 `run_once`(预警 + 清零)。
|
|
|
|
健壮性:
|
|
- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。
|
|
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
|
|
- **开关**:settings.INACTIVITY_RESET_ENABLED=false 时不启动(默认关,灰度验证后再开)。
|
|
|
|
⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见
|
|
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(),
|
|
)
|
|
|
|
|
|
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",
|
|
interval,
|
|
settings.INACTIVITY_RESET_RUN_HOUR,
|
|
)
|
|
# 本进程上次跑过的北京日;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:
|
|
if not settings.INACTIVITY_RESET_ENABLED:
|
|
logger.info("inactivity reset disabled (INACTIVITY_RESET_ENABLED=false)")
|
|
return None
|
|
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
|