"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。 活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。 逐用户独立事务,一个失败不影响其余。 """ from __future__ import annotations import logging from datetime import date, datetime from sqlalchemy import or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.integrations.notifier import InactivityNotifier from app.models.inactivity import InactivityNotificationLog, InactivityResetLog from app.models.user import User from app.models.wallet import CoinAccount from app.repositories import activity from app.repositories import wallet as wallet_repo logger = logging.getLogger("shagua.inactivity") RESET_BIZ_TYPE = "inactivity_reset" RESET_REMARK = "15天不活跃清零" # 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零 # (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。 _ANY_BALANCE = or_( CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0, ) def _base_query(db: Session): """select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。""" ev_sub, eng_sub = activity.last_active_subqueries(db) dialect = db.get_bind().dialect.name last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) stmt = ( select( User.id.label("user_id"), last_active.label("last_active"), CoinAccount.coin_balance, CoinAccount.cash_balance_cents, CoinAccount.invite_cash_balance_cents, ) .join(CoinAccount, CoinAccount.user_id == User.id) .outerjoin(ev_sub, ev_sub.c.user_id == User.id) .outerjoin(eng_sub, eng_sub.c.user_id == User.id) ) return stmt, last_active def _cn_date(dt: datetime) -> date: """datetime → 北京自然日(naive 视为 UTC)。""" return activity.norm_utc(dt).astimezone(CN_TZ).date() def _inactive_days(last_active: datetime, today: date) -> int: return (today - _cn_date(last_active)).days def select_inactive_users(db: Session, *, cutoff: datetime): """应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。 返回 Row 列表(值已快照,可跨 commit)。""" stmt, last_active = _base_query(db) stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff)) return db.execute(stmt).all() def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str, dry_run: bool = False) -> bool: """单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清** (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真处理了(有可清余额)。 dry_run=True:**只写审计名单、不动钱不写流水**(灰度看名单)。按 streak 去重——本 streak 已记过(reset_at > last_active)就跳,避免 worker 每日重复记。""" acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额" return False if dry_run and db.execute( select(InactivityResetLog.id).where( InactivityResetLog.user_id == user_id, InactivityResetLog.reset_at > activity.as_utc(last_active), ).limit(1) ).first(): return False # dry-run:本 streak 已记过审计,不重复记 log = InactivityResetLog( user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash, invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active), inactive_days=inactive_days, reason=reason, ) db.add(log) db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水 if not dry_run: # dry-run 只记审计名单,不真出账 ref = str(log.id) if coin: wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) if cash: wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 db.commit() return True def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict: """扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。""" stats = {"scanned": 0, "cleared": 0, "failed": 0} cutoff = activity.reset_cutoff(reset_days, today) reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "") rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit for row in rows: stats["scanned"] += 1 idays = _inactive_days(row.last_active, today) try: if clear_user(db, user_id=row.user_id, last_active=row.last_active, inactive_days=idays, reason=reason, dry_run=dry_run): stats["cleared"] += 1 except SQLAlchemyError: db.rollback() stats["failed"] += 1 return stats def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime): """预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。""" stmt, last_active = _base_query(db) stmt = stmt.where( _ANY_BALANCE, last_active >= activity.as_utc(clear_cutoff), last_active < activity.as_utc(warn_hi), ) return db.execute(stmt).all() def run_warn_once(db: Session, notifier: InactivityNotifier, *, reset_days: int, warn_stages: list[int], today: date) -> dict: """扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。 预警只涉及会被清的金币 + 折算现金;邀请现金不清、不预警(仅在 notification_log 记快照)。 逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零。""" stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} if not warn_stages: return stats clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警 warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界 ascending = sorted(warn_stages) # 最紧急(最小 k)在前 for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi): idays = _inactive_days(row.last_active, today) stage = next((k for k in ascending if idays >= reset_days - k), None) if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达 continue try: already = db.execute( select(InactivityNotificationLog.id).where( InactivityNotificationLog.user_id == row.user_id, InactivityNotificationLog.stage == stage, InactivityNotificationLog.created_at > activity.as_utc(row.last_active), ).limit(1) ).first() if already: stats["warn_skipped"] += 1 continue status = notifier.warn( user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents, stage=stage, days_until_reset=reset_days - idays, ) db.add(InactivityNotificationLog( user_id=row.user_id, stage=stage, inactive_days=idays, coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents, invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度 channel=notifier.channel, status=status, )) db.commit() stats["warned"] += 1 except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零 db.rollback() stats["warn_failed"] += 1 return stats def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int, warn_stages: list[int], today: date, dry_run: bool = False) -> dict: """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。 dry_run=True(灰度默认):只记审计名单、不清、**也不预警**(不通知一个不会发生的清零)。""" warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} if not dry_run: try: warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零 logger.exception("inactivity warn phase failed; proceeding to reset") db.rollback() warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1} reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run) return {**warn, **reset}