diff --git a/app/core/config.py b/app/core/config.py index bf27932..9829b8b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -169,6 +169,14 @@ class Settings(BaseSettings): # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 + # 连续 N 天无活跃(无比价 / 领券,登录不算)账户金币+现金清零:进程内 worker + # app.core.inactive_clear_worker 跨北京 0 点跑一轮 inactive_clear.clear_inactive_accounts。 + # 邀请奖励金物理隔离,不在清空范围。⚠️ 不可逆批量资金操作,口径见 repositories/inactive_clear。 + # 运营要临时停在 .env 置 false(worker 不启动)。 + INACTIVE_CLEAR_ENABLED: bool = True + # 连续无活跃**满这么多整天之后**才清:15 → 最后活跃在 15 天前当天仍保留,第 16 天起清。 + INACTIVE_CLEAR_DAYS: int = 15 + INACTIVE_CLEAR_CHECK_INTERVAL_SEC: int = 600 # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 diff --git a/app/core/inactive_clear_worker.py b/app/core/inactive_clear_worker.py new file mode 100644 index 0000000..566ccbe --- /dev/null +++ b/app/core/inactive_clear_worker.py @@ -0,0 +1,132 @@ +"""连续 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 diff --git a/app/main.py b/app/main.py index 3b3b6ff..d542ef6 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,10 @@ from app.core.heartbeat_monitor_worker import ( start_heartbeat_monitor, stop_heartbeat_monitor, ) +from app.core.inactive_clear_worker import ( + start_inactive_clear_worker, + stop_inactive_clear_worker, +) from app.core.logging import setup_logging from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client from app.core.withdraw_reconcile_worker import ( @@ -74,18 +78,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: try: # 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求 from app.utils import geo + geo.ensure_loaded() except Exception: # noqa: BLE001 logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)") reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() + inactive_clear_task = start_inactive_clear_worker() try: yield finally: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) + await stop_inactive_clear_worker(inactive_clear_task) await aclose_pricebot_client() logger.info("shutting down") diff --git a/app/repositories/inactive_clear.py b/app/repositories/inactive_clear.py new file mode 100644 index 0000000..2675b1e --- /dev/null +++ b/app/repositories/inactive_clear.py @@ -0,0 +1,168 @@ +"""连续 N 天无活跃账户的金币 / 现金清零。 + +规则(2026-07-15 产品定):某用户连续 `days` 天(默认 15)既没发起比价、也没发起领券 +→ 判定为「流失」,清空其金币余额(coin_balance)与金币兑换现金(cash_balance_cents)。 +**单纯登录(打开 App)不算活跃**——光登录、不用比价/领券的用户视为流失照清。 +**邀请奖励金(invite_cash_balance_cents)物理隔离,不在清空范围**(产品红线,见 +models/wallet.py CoinAccount 注释);total_coin_earned(历史累计赚取,只增不减)也不动。 + +活跃口径:发起比价 real_compare_start + 发起领券 real_coupon_start / claim_started +两路并集(**不含登录 last_login_at**)。⚠️与 admin 大盘 DAU / 后台「最近活跃」列**不同** +——那两处含登录,此处刻意去掉,只认真正用了核心功能;若两处口径要同步须留意此差异。 + +新用户保护:从没发起过比价/领券的用户永远不在活跃集合,故用**注册时间兜底**——注册未满 +days 天(created_at 在 today-days 之后)的用户豁免清零,新用户有 days 天宽限,不会刚注册 +(有礼金)就被清。(登录已不算活跃,故不能再靠 last_login 兜底新用户。) + +清零走 wallet.grant_coins / grant_cash(负数出账入口):更新余额快照 + 写一笔 +biz_type=inactive_clear 的流水(带 balance_after),可逐笔回溯 / 人工恢复;grant_coins +对负数不累加 total_coin_earned。 +""" +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.core import rewards +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories.wallet import get_or_create_account, grant_cash, grant_coins + +CLEAR_BIZ_TYPE = "inactive_clear" +# 「活跃」计入的埋点事件:发起比价 + 发起领券(登录不算) +_ACTIVE_EVENTS = ("real_compare_start", "real_coupon_start") + + +def _cn_date_start_utc(d: date) -> datetime: + """北京自然日 d 的 00:00 → UTC aware 下界。 + + analytics_event.created_at / user.created_at 存 UTC(见各自模型),须用 UTC 边界比较; + CN_TZ 是固定 +8 偏移(rewards.CN_TZ),无 DST 歧义。 + """ + return datetime(d.year, d.month, d.day, tzinfo=rewards.CN_TZ).astimezone(UTC) + + +def _active_user_ids_since(db: Session, since_cn: date) -> set[int]: + """北京自然日 since_cn(含)当天 0 点起 **发起过比价 / 领券** 的 user_id 集合。 + + **登录不算活跃**(产品定):只认核心功能行为。两路并集: + - 发起比价 / 领券:analytics_event.event in _ACTIVE_EVENTS 且 created_at >= since(UTC 边界) + - 发起领券:coupon_prompt_engagement.engage_type=claim_started 且 engage_date >= since(北京日列) + """ + since_utc = _cn_date_start_utc(since_cn) + ids: set[int] = set() + + ids.update( + uid + for uid in db.execute( + select(AnalyticsEvent.user_id).where( + AnalyticsEvent.user_id.is_not(None), + AnalyticsEvent.event.in_(_ACTIVE_EVENTS), + AnalyticsEvent.created_at >= since_utc, + ) + ).scalars() + if uid is not None + ) + ids.update( + uid + for uid in db.execute( + select(CouponPromptEngagement.user_id).where( + CouponPromptEngagement.user_id.is_not(None), + CouponPromptEngagement.engage_type == "claim_started", + CouponPromptEngagement.engage_date >= since_cn, + ) + ).scalars() + if uid is not None + ) + return ids + + +def _new_user_ids_since(db: Session, since_cn: date) -> set[int]: + """注册时间 >= since_cn 北京日 0 点的 user_id——注册未满 days 天的新用户,豁免清零。""" + since_utc = _cn_date_start_utc(since_cn) + return set(db.execute(select(User.id).where(User.created_at >= since_utc)).scalars()) + + +def clear_inactive_accounts(db: Session, *, days: int, dry_run: bool = False) -> dict: + """连续无活跃**满 `days` 整天之后**把用户金币 + 金币现金清零(邀请金不动),逐用户独立事务。 + + - 活跃 = 发起比价 / 发起领券(**登录不算**)。last_active >= today-days 视为活跃;连续无 + 比价无领券满 days 天(且注册也满 days 天)才清。days=15 → 最后一次比价/领券在 15 天前 + 当天仍留、第 16 天起清。today 一律北京时(rewards.cn_today())。 + - 新用户保护:注册未满 days 天(created_at 在 today-days 之后)豁免,不会刚注册就被清。 + - 只扫描有余额(coin_balance>0 或 cash_balance_cents>0)的账户:清零后余额=0,下一轮 + 自然跳过 → 天然幂等,重启 / 多次唤醒 / 补跑都安全。 + - 逐用户独立事务:单用户异常 rollback 不影响其他人。 + - dry_run=True:只统计不写库(供运营上线前预演:看会清哪些人、清多少)。 + + 返回统计 dict(scanned/cleared/skipped_active/skipped_new_user/coin_cleared/ + cents_cleared/failed)。 + """ + # days<1 会把 active_since 推到未来 → 全员判流失清空(灾难);防御性钳到 >=1。 + days = max(1, days) + today = rewards.cn_today() + # 连续无活跃满 days 整天之后才清:active_since=today-days,活跃/新用户都以此为边界。 + active_since = today - timedelta(days=days) + active_ids = _active_user_ids_since(db, active_since) + new_user_ids = _new_user_ids_since(db, active_since) + + # 只取候选 user_id;清零金额在循环内读**实时**余额(不用此处快照)——活跃集合算完到逐行 + # 清零之间余额可能变(如并发看广告发币),用快照 grant 会清不干净、balance_after≠0。 + candidate_ids = ( + db.execute( + select(CoinAccount.user_id).where( + or_(CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0) + ) + ) + .scalars() + .all() + ) + + stats = { + "scanned": 0, + "cleared": 0, + "skipped_active": 0, + "skipped_new_user": 0, + "coin_cleared": 0, + "cents_cleared": 0, + "failed": 0, + } + remark = f"连续{days}天无活跃清零" + for user_id in candidate_ids: + stats["scanned"] += 1 + if user_id in active_ids: + stats["skipped_active"] += 1 + continue + if user_id in new_user_ids: # 注册未满 days 天,新用户宽限 + stats["skipped_new_user"] += 1 + continue + if dry_run: + acc = db.get(CoinAccount, user_id) + if acc is not None: + stats["cleared"] += 1 + stats["coin_cleared"] += acc.coin_balance + stats["cents_cleared"] += acc.cash_balance_cents + continue + try: + # lock=True 对该账户行加 FOR UPDATE(PG 生效,SQLite no-op),读-清-写串行化防并发 + acc = get_or_create_account(db, user_id, commit=False, lock=True) + coin, cents = acc.coin_balance, acc.cash_balance_cents + if coin <= 0 and cents <= 0: + continue # 快照后被并发清空 / 变动,无需再清 + if coin > 0: + grant_coins(db, user_id, -coin, biz_type=CLEAR_BIZ_TYPE, remark=remark) + if cents > 0: + grant_cash(db, user_id, -cents, biz_type=CLEAR_BIZ_TYPE, remark=remark) + db.commit() + stats["cleared"] += 1 + stats["coin_cleared"] += coin + stats["cents_cleared"] += cents + except Exception: # noqa: BLE001 - 批处理不因单用户异常中断 + db.rollback() + stats["failed"] += 1 + + return stats