Files
shaguabijia-app-server/docs/superpowers/plans/2026-07-16-inactivity-reset.md
T
guke a86688ccfb feat(welfare): 15 天不活跃清零(金币+折算现金,邀请金不清)+ 预警 + admin 活跃口径统一 (#143)
背景 / 目标
连续 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
2026-07-18 19:11:45 +08:00

57 KiB
Raw Blame History

15 天不活跃清零(金币/现金) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 连续 15 天不活跃(北京自然日、第 16 日 0 点对齐)的用户,自动清零金币+折算现金(邀请现金不清——产品红线),清零前按可配置节奏预警,全程留审计。

更新(2026-07-18): 邀请现金(invite_cash_balance_cents)由"三桶全清"改为不清——遵循 wallet.CoinAccount 的"物理隔离/产品红线"、与另一实现分支对齐。inactivity.clear_user 现码为准;下方 Task 7 的历史代码片段仍写三桶归零,阅读时以现码为准。另:首页可见事件已定名 event=show + page=home(原占位 home_view),见 activity.active_event_condition;下文/片段的 home_view 均指此信号。

Architecture: 活跃口径抽成共享模块 app/repositories/activity.py(worker 与 admin 共用,单一真源);业务逻辑在 app/repositories/inactivity.py(纯同步、可单测);进程内每日 asyncio worker app/core/inactivity_reset_worker.py(仿 daily_exchange_worker,文件锁 + 北京日守卫 + 总闸)。预警走可插拔 InactivityNotifier,v1 为日志占位。审计双写:inactivity_reset_log 专表 + 钱包流水 biz_type=inactivity_reset

Tech Stack: FastAPI · SQLAlchemy 2.0 (Mapped) · Alembic (render_as_batch) · pydantic-settings · pytest + TestClient/SQLite。

Spec: docs/superpowers/specs/2026-07-16-inactivity-reset-design.md

活跃口径(最终): last_active = max(User.created_at, AnalyticsEvent[home_view/real_compare_start/real_coupon_start], CouponPromptEngagement[claim_started])不含 last_login_at;created_at 为恒非空基线。home_view 事件名前端明天敲定,后端以常量 HOME_VIEW_EVENT 占位(暂 "home_view")。

清零边界: 末次活跃日记为「第 1 日」→ 第 16 日 0 点(北京)清零 = 北京 00:00 of (last_active_date + RESET_DAYS)。等价 应清零 ⟺ last_active < reset_cutoff = 北京 00:00 of (cn_today() (RESET_DAYS1))


File Structure

新增

  • app/models/inactivity.pyInactivityResetLog + InactivityNotificationLog(两个小关联模型同文件,仿 wallet.py 多模型同文件)
  • app/repositories/activity.py — 活跃口径唯一真源(常量 + tz 助手 + cutoff + 子查询 + last_active_expr)
  • app/integrations/notifier.pyInactivityNotifier 协议 + LogNotifier + get_notifier
  • app/repositories/inactivity.py — 选取/清零/预警业务逻辑(纯同步)
  • app/core/inactivity_reset_worker.py — 每日 worker(asyncio + 文件锁 + 守卫 + 启停)
  • alembic/versions/<hash>_add_inactivity_tables.py — 建两表迁移
  • tests/test_inactivity_reset.py — 单测/集成

改动

  • app/models/__init__.py — 注册两模型
  • app/core/config.pyINACTIVITY_* 配置 + inactivity_warn_stages 属性
  • app/main.py — lifespan 接线 start/stop worker
  • app/admin/repositories/queries.pyapp/admin/repositories/stats.py — 改用 activity.py(移除本地重复口径)

Task 1: 两张新表模型 + 注册

Files:

  • Create: app/models/inactivity.py

  • Modify: app/models/__init__.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

Create tests/test_inactivity_reset.py:

"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。"""
from __future__ import annotations

from datetime import date, datetime, timedelta, timezone

from sqlalchemy import select

from app.db.session import SessionLocal
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog


def test_reset_and_notification_models_persist() -> None:
    db = SessionLocal()
    try:
        db.add(InactivityResetLog(
            user_id=1, coin_balance_before=10, cash_balance_cents_before=20,
            invite_cash_balance_cents_before=30,
            last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            inactive_days=15, reason="inactive_15d",
        ))
        db.add(InactivityNotificationLog(
            user_id=1, stage=7, inactive_days=8, coin_balance=10,
            cash_balance_cents=20, invite_cash_balance_cents=30,
            channel="log", status="placeholder",
        ))
        db.commit()
        r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one()
        assert r.reason == "inactive_15d" and r.reset_at is not None
        n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one()
        assert n.stage == 7 and n.created_at is not None
    finally:
        db.rollback()
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_reset_and_notification_models_persist -q Expected: FAIL — ModuleNotFoundError: No module named 'app.models.inactivity'

  • Step 3: Create the models

Create app/models/inactivity.py:

"""15 天不活跃清零相关表。

- inactivity_reset_log:每次清零一行,记清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数,
  供纠纷排查(需求①)。清零同时另写 3 条钱包流水(biz_type=inactivity_reset),资金流可逐笔回溯。
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
  兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。

append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。
"""
from __future__ import annotations

from datetime import datetime

from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class InactivityResetLog(Base):
    __tablename__ = "inactivity_reset_log"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
    coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
    cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
    invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
    last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
    reason: Mapped[str] = mapped_column(String(32), nullable=False)
    reset_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
    )

    def __repr__(self) -> str:  # pragma: no cover
        return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"


class InactivityNotificationLog(Base):
    __tablename__ = "inactivity_notification_log"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
    stage: Mapped[int] = mapped_column(Integer, nullable=False)      # 提前天数档(如 7 / 2)
    inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
    coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
    cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
    invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
    channel: Mapped[str] = mapped_column(String(16), nullable=False)   # log / jpush / sms
    status: Mapped[str] = mapped_column(String(16), nullable=False)    # placeholder / sent / failed
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
    )

    def __repr__(self) -> str:  # pragma: no cover
        return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
  • Step 4: Register in models/__init__.py

In app/models/__init__.py, add after the from app.models.invite import ... line (keep alphabetical-ish grouping near other domain models):

from app.models.inactivity import (  # noqa: F401
    InactivityNotificationLog,
    InactivityResetLog,
)
  • Step 5: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py::test_reset_and_notification_models_persist -q Expected: PASS (conftest builds all tables via Base.metadata.create_all, so the new tables exist in the SQLite test DB.)

  • Step 6: Commit
git add app/models/inactivity.py app/models/__init__.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): inactivity_reset_log + inactivity_notification_log 模型"

Task 2: Alembic 迁移建两表

Files:

  • Create: alembic/versions/<hash>_add_inactivity_tables.py (由 autogenerate 生成文件名/revision)

  • Step 1: Confirm single head

Run: alembic heads Expected: 恰好一个 head(单行)。若多个 head,先 alembic merge -m "merge heads" <headA> <headB> 再继续。

  • Step 2: Autogenerate the migration

Run: alembic revision --autogenerate -m "add inactivity tables" Expected: 在 alembic/versions/ 生成一个新文件,down_revision 自动指向当前 head。

  • Step 3: Replace the migration body

打开生成的文件,只保留新两表(如 autogenerate 顺带检出其它表的历史索引漂移,删掉那些无关 op,仿 1699fc2c069f_add_analytics_event_table.py:50-51 的做法)。upgrade/downgrade 改成:

def upgrade() -> None:
    op.create_table(
        "inactivity_reset_log",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("user_id", sa.Integer(), nullable=False),
        sa.Column("coin_balance_before", sa.Integer(), nullable=False),
        sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
        sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
        sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("inactive_days", sa.Integer(), nullable=False),
        sa.Column("reason", sa.String(length=32), nullable=False),
        sa.Column("reset_at", sa.DateTime(timezone=True),
                  server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
        sa.PrimaryKeyConstraint("id"),
    )
    with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
        batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
        batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)

    op.create_table(
        "inactivity_notification_log",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("user_id", sa.Integer(), nullable=False),
        sa.Column("stage", sa.Integer(), nullable=False),
        sa.Column("inactive_days", sa.Integer(), nullable=False),
        sa.Column("coin_balance", sa.Integer(), nullable=False),
        sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
        sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
        sa.Column("channel", sa.String(length=16), nullable=False),
        sa.Column("status", sa.String(length=16), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True),
                  server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
        sa.PrimaryKeyConstraint("id"),
    )
    with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
        batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
        batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)


def downgrade() -> None:
    with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
        batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
        batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
    op.drop_table("inactivity_notification_log")
    with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
        batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
        batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
    op.drop_table("inactivity_reset_log")

确保文件顶部保留自动生成的 import sqlalchemy as sa / from alembic import op / revision / down_revision

  • Step 4: Apply and verify round-trips

Run: alembic upgrade head && alembic downgrade -1 && alembic upgrade head Expected: 三步都无错;upgrade 建表、downgrade 删表、再 upgrade 重建。

  • Step 5: Commit
git add alembic/versions/
git commit -m "feat(welfare): 迁移新增 inactivity_reset_log / inactivity_notification_log 两表"

Task 3: 活跃口径共享模块 — 常量 + tz 助手 + cutoff(纯函数)

Files:

  • Create: app/repositories/activity.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

追加到 tests/test_inactivity_reset.py:

from app.repositories import activity


def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None:
    # RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC
    cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20))
    assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc)


def test_active_event_constants() -> None:
    assert activity.HOME_VIEW_EVENT in activity.ACTIVE_EVENTS
    assert "real_compare_start" in activity.ACTIVE_EVENTS
    assert "real_coupon_start" in activity.ACTIVE_EVENTS
    assert activity.ACTIVE_ENGAGE_TYPE == "claim_started"


def test_as_utc_normalizes() -> None:
    assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc)
    cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ)  # 北京 0 点 = 前一天 16:00 UTC
    assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc)
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py -k "cutoff or constants or as_utc" -q Expected: FAIL — ModuleNotFoundError: No module named 'app.repositories.activity'

  • Step 3: Create the module (constants + helpers only for now)

Create app/repositories/activity.py:

"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。

口径 = max(User.created_at, AnalyticsEvent[ACTIVE_EVENTS], CouponPromptEngagement[claim_started])。
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。
清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。
"""
from __future__ import annotations

from datetime import date, datetime, timedelta, timezone

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.core.rewards import CN_TZ, cn_today
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement

# —— 活跃事件名(与"用户管理"口径一致)——
# home_view:进首页(前端埋点,名称前端明天敲定,此处占位;定名后仅改这一常量)。
HOME_VIEW_EVENT = "home_view"
COMPARE_START_EVENT = "real_compare_start"   # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start"     # 发起领券
ACTIVE_EVENTS = (HOME_VIEW_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_ENGAGE_TYPE = "claim_started"         # coupon_prompt_engagement 一键领取


def as_utc(value: datetime) -> datetime:
    """任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
    比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。"""
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


def norm_utc(dt: datetime | None) -> datetime | None:
    """naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
    if dt is None:
        return None
    return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)


def cn_midnight_utc(d: date) -> datetime:
    """北京 d 日 00:00 → tz-aware UTC datetime。"""
    return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))


def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
    """应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
    = 北京 00:00 of (today  (reset_days  1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。"""
    today = today or cn_today()
    return cn_midnight_utc(today - timedelta(days=reset_days - 1))
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py -k "cutoff or constants or as_utc" -q Expected: PASS

  • Step 5: Commit
git add app/repositories/activity.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 活跃口径共享模块-常量与北京0点cutoff助手"

Task 4: 活跃口径共享模块 — 子查询 + last_active_expr(DB)

Files:

  • Modify: app/repositories/activity.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

追加测试助手 + 用例到 tests/test_inactivity_reset.py:

from app.core.rewards import CN_TZ
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 import wallet as wallet_repo

_PHONE_SEQ = [0]


def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int:
    """直接建一个 User + CoinAccount,created_at 可控。返回 user_id。"""
    _PHONE_SEQ[0] += 1
    u = User(phone=f"139{_PHONE_SEQ[0]:08d}", created_at=created_at,
             last_login_at=created_at, status="active")
    db.add(u)
    db.flush()
    acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
    acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
    acc.total_coin_earned = coin
    db.flush()
    return u.id


def _add_event(db, user_id, event, when: datetime) -> None:
    db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, created_at=when))


def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None:
    db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id,
                                  engage_date=when.date(), engage_type=engage_type, created_at=when))


def test_last_active_expr_takes_max_of_baseline_and_events() -> None:
    from sqlalchemy import select
    db = SessionLocal()
    try:
        base = datetime(2026, 1, 1, tzinfo=timezone.utc)
        uid = _new_user(db, created_at=base, coin=5)
        _add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc))
        db.commit()
        ev_sub, eng_sub = activity.last_active_subqueries(db)
        dialect = db.get_bind().dialect.name
        expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
        stmt = (select(expr).select_from(User)
                .outerjoin(ev_sub, ev_sub.c.user_id == User.id)
                .outerjoin(eng_sub, eng_sub.c.user_id == User.id)
                .where(User.id == uid))
        got = activity.norm_utc(db.execute(stmt).scalar_one())
        assert got == datetime(2026, 1, 10, tzinfo=timezone.utc)  # 事件 > 基线
    finally:
        db.rollback()
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_last_active_expr_takes_max_of_baseline_and_events -q Expected: FAIL — AttributeError: module 'app.repositories.activity' has no attribute 'last_active_subqueries'

  • Step 3: Add subqueries + expr to activity.py

app/repositories/activity.py 末尾追加:

def last_active_subqueries(db: Session):
    """两个按 user_id 预聚合的派生表:最近活跃事件(ACTIVE_EVENTS)、最近领券发起(claim_started)。
    返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用,借事件索引只扫活跃事件。"""
    ev_sub = (
        select(
            AnalyticsEvent.user_id.label("user_id"),
            func.max(AnalyticsEvent.created_at).label("last_at"),
        )
        .where(AnalyticsEvent.user_id.is_not(None), AnalyticsEvent.event.in_(ACTIVE_EVENTS))
        .group_by(AnalyticsEvent.user_id)
        .subquery()
    )
    eng_sub = (
        select(
            CouponPromptEngagement.user_id.label("user_id"),
            func.max(CouponPromptEngagement.created_at).label("last_at"),
        )
        .where(
            CouponPromptEngagement.user_id.is_not(None),
            CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
        )
        .group_by(CouponPromptEngagement.user_id)
        .subquery()
    )
    return ev_sub, eng_sub


def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
    """max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
    子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。"""
    greatest = func.greatest if dialect == "postgresql" else func.max
    return greatest(
        base_col,
        func.coalesce(ev_sub.c.last_at, base_col),
        func.coalesce(eng_sub.c.last_at, base_col),
    )
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py::test_last_active_expr_takes_max_of_baseline_and_events -q Expected: PASS

  • Step 5: Commit
git add app/repositories/activity.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 活跃口径共享模块-子查询与 last_active_expr"

Task 5: 配置项

Files:

  • Modify: app/core/config.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

追加:

def test_inactivity_warn_stages_parsing() -> None:
    from app.core.config import Settings
    s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15)
    assert s.inactivity_warn_stages == [7, 2]        # 降序去重
    s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15)
    assert s2.inactivity_warn_stages == []           # 空=不推
    s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15)
    assert s3.inactivity_warn_stages == [7, 2]        # 去重 + 丢弃 >=RESET_DAYS(20)
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_inactivity_warn_stages_parsing -q Expected: FAIL — TypeError: ... unexpected keyword argument 'INACTIVITY_WARN_DAYS_BEFORE'

  • Step 3: Add settings

app/core/config.pySettings 类里,紧接 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600(约 line 171)之后加:

    # === 15 天不活跃清零(app.core.inactivity_reset_worker)===
    INACTIVITY_RESET_ENABLED: bool = False           # 总闸,默认关;灰度验证后再开
    INACTIVITY_RESET_DAYS: int = 15                  # 不活跃阈值(天),第 (N+1) 日 0 点清
    INACTIVITY_WARN_DAYS_BEFORE: str = "7,2"         # 清零前几天各推一次;""=不推。逗号分隔
    INACTIVITY_RESET_RUN_HOUR: int = 3               # 北京时间每日执行点(0-23)
    INACTIVITY_NOTIFY_CHANNEL: str = "log"           # log(占位) / jpush / sms
    INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800  # worker 唤醒间隔(秒)

并在类内(与其它 @property 放一起,如 wxpay_configured 附近)加解析属性:

    @property
    def inactivity_warn_stages(self) -> list[int]:
        """解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
        丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。"""
        out: list[int] = []
        for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
            part = part.strip()
            if part.isdigit():
                v = int(part)
                if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
                    out.append(v)
        return sorted(out, reverse=True)
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py::test_inactivity_warn_stages_parsing -q Expected: PASS

  • Step 5: Commit
git add app/core/config.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): INACTIVITY_* 配置项 + warn stages 解析"

Task 6: 可插拔通知器

Files:

  • Create: app/integrations/notifier.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

def test_log_notifier_returns_placeholder(caplog) -> None:
    from app.integrations.notifier import LogNotifier, get_notifier
    n = get_notifier("log")
    assert isinstance(n, LogNotifier) and n.channel == "log"
    status = n.warn(user_id=1, coin=10, cash_cents=20, invite_cash_cents=30, stage=7, days_until_reset=8)
    assert status == "placeholder"
    # 未实现通道回退 LogNotifier(占位)
    assert get_notifier("jpush").channel == "log"
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_log_notifier_returns_placeholder -q Expected: FAIL — ModuleNotFoundError: No module named 'app.integrations.notifier'

  • Step 3: Create the notifier

Create app/integrations/notifier.py:

"""不活跃预警通知器(可插拔)。

v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier /
SmsNotifier 即可替换,worker/repo 不改。
"""
from __future__ import annotations

import logging
from typing import Protocol

logger = logging.getLogger("shagua.inactivity")


class InactivityNotifier(Protocol):
    channel: str

    def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
             stage: int, days_until_reset: int) -> str:
        """发预警,返回状态:'sent' / 'failed' / 'placeholder'。"""
        ...


class LogNotifier:
    """占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""

    channel = "log"

    def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int,
             stage: int, days_until_reset: int) -> str:
        logger.warning(
            "[inactivity-warn] user=%s coin=%s cash_cents=%s invite_cash_cents=%s "
            "stage=T-%s days_until_reset=%s",
            user_id, coin, cash_cents, invite_cash_cents, stage, days_until_reset,
        )
        return "placeholder"


def get_notifier(channel: str) -> InactivityNotifier:
    """按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
    # 后续:if channel == "jpush": return JPushNotifier()
    #       if channel == "sms":   return SmsNotifier()
    return LogNotifier()
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py::test_log_notifier_returns_placeholder -q Expected: PASS

  • Step 5: Commit
git add app/integrations/notifier.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 可插拔不活跃预警通知器 + LogNotifier 占位"

Task 7: 业务逻辑 — 选取 + 清零

Files:

  • Create: app/repositories/inactivity.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

def test_run_reset_clears_three_buckets_and_writes_audit_and_flows() -> None:
    from sqlalchemy import select
    from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清)
        old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
                        coin=100, cash=200, invite=300)
        # 活跃用户:昨天有 home_view → 不清
        fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50)
        _add_event(db, fresh, "home_view", datetime(2026, 1, 31, tzinfo=timezone.utc))
        db.commit()

        stats = inactivity.run_reset_once(db, reset_days=15, today=today)
        assert stats["cleared"] == 1 and stats["failed"] == 0

        acc = db.get(CoinAccount, old)
        assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (0, 0, 0)
        assert acc.total_coin_earned == 100  # 历史累计不动

        log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
        assert (log.coin_balance_before, log.cash_balance_cents_before,
                log.invite_cash_balance_cents_before) == (100, 200, 300)
        assert log.inactive_days == 22 and log.reason == "inactive_15d"

        ct = db.execute(select(CoinTransaction).where(
            CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one()
        assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id)
        assert db.execute(select(CashTransaction).where(
            CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200
        assert db.execute(select(InviteCashTransaction).where(
            InviteCashTransaction.user_id == old, InviteCashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -300

        # 活跃用户不动;再跑一次幂等(已清零 → 不再匹配)
        assert db.get(CoinAccount, fresh).coin_balance == 50
        assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0
    finally:
        db.rollback()
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_run_reset_clears_three_buckets_and_writes_audit_and_flows -q Expected: FAIL — ModuleNotFoundError: No module named 'app.repositories.inactivity'

  • Step 3: Create the repository (selection + clear + run_reset_once)

Create app/repositories/inactivity.py:

"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。

活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。
逐用户独立事务,一个失败不影响其余。
"""
from __future__ import annotations

from datetime import date, datetime, timezone

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

RESET_BIZ_TYPE = "inactivity_reset"
RESET_REMARK = "15天不活跃清零"

_ANY_BALANCE = or_(
    CoinAccount.coin_balance > 0,
    CoinAccount.cash_balance_cents > 0,
    CoinAccount.invite_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) -> bool:
    """单用户清零(独立事务、行锁)。三桶归零 + 写审计 + 3 条流水。返回是否真清了(有余额)。"""
    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 and invite == 0:
        return False
    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 交叉链接审计↔流水
    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)
    if invite:
        wallet_repo.grant_invite_cash(db, user_id, -invite, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
    db.commit()
    return True


def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict:
    """扫一轮清零。逐用户独立 commit,失败隔离。"""
    stats = {"scanned": 0, "cleared": 0, "failed": 0}
    cutoff = activity.reset_cutoff(reset_days, today)
    reason = f"inactive_{reset_days}d"
    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):
                stats["cleared"] += 1
        except SQLAlchemyError:
            db.rollback()
            stats["failed"] += 1
    return stats
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py::test_run_reset_clears_three_buckets_and_writes_audit_and_flows -q Expected: PASS

  • Step 5: Commit
git add app/repositories/inactivity.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 不活跃清零-选取与逐用户清零(三桶归零+审计+流水)"

Task 8: 业务逻辑 — 预警 + run_once 组合

Files:

  • Modify: app/repositories/inactivity.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

def test_run_warn_picks_stage_and_dedups_within_streak() -> None:
    from app.integrations.notifier import LogNotifier
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        # 末次活跃 1/22(距 today 10 天)→ 档 7 命中(idays>=8),档 2 未到(需>=13)
        uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=100)
        db.commit()

        stats = inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
        assert stats["warned"] == 1
        from sqlalchemy import select
        rows = db.execute(select(InactivityNotificationLog).where(
            InactivityNotificationLog.user_id == uid)).scalars().all()
        assert len(rows) == 1 and rows[0].stage == 7 and rows[0].status == "placeholder"
        assert rows[0].inactive_days == 10 and rows[0].coin_balance == 100

        # 同一 streak 再跑 → 不重推
        assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0

        # 无余额用户不预警
        _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=0)
        db.commit()
        assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0
    finally:
        db.rollback()
        db.close()


def test_run_once_warns_then_resets() -> None:
    from app.integrations.notifier import LogNotifier
    from app.repositories import inactivity
    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10)   # 10天→预警
        clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10)   # 27天→清零
        db.commit()
        stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
        assert stats["warned"] == 1 and stats["cleared"] == 1
        from app.models.wallet import CoinAccount
        assert db.get(CoinAccount, clear_uid).coin_balance == 0
        assert db.get(CoinAccount, warn_uid).coin_balance == 10   # 预警不动钱
    finally:
        db.rollback()
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py -k "run_warn or run_once" -q Expected: FAIL — AttributeError: module 'app.repositories.inactivity' has no attribute 'run_warn_once'

  • Step 3: Add warn + run_once to inactivity.py

app/repositories/inactivity.py 末尾追加:

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)。"""
    stats = {"warned": 0, "warn_skipped": 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:
            continue
        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,
            invite_cash_cents=row.invite_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
    return stats


def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int,
            warn_stages: list[int], today: date) -> dict:
    """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。"""
    warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today)
    reset = run_reset_once(db, reset_days=reset_days, today=today)
    return {**warn, **reset}
  • Step 4: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py -k "run_warn or run_once" -q Expected: PASS

  • Step 5: Commit
git add app/repositories/inactivity.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 不活跃预警(分档+streak去重)+ run_once 组合"

Task 9: 进程内每日 worker + lifespan 接线

Files:

  • Create: app/core/inactivity_reset_worker.py

  • Modify: app/main.py

  • Test: tests/test_inactivity_reset.py

  • Step 1: Write the failing test

def test_worker_disabled_returns_none(monkeypatch) -> None:
    from app.core import inactivity_reset_worker as w
    from app.core.config import settings
    monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False)
    assert w.start_inactivity_reset_worker() is None


def test_worker_run_once_entry_executes(monkeypatch) -> None:
    """_run_once_entry 用真实 SessionLocal 跑一轮,总闸开时能清掉一个不活跃用户。"""
    from app.core import inactivity_reset_worker as w
    from app.core.config import settings
    from app.models.wallet import CoinAccount

    monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", True)
    monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15)
    monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "")  # 只测清零
    # 固定"今天"避免依赖真实时钟
    monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1))

    db = SessionLocal()
    try:
        uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100)
        db.commit()
    finally:
        db.close()

    stats = w._run_once_entry()
    assert stats["cleared"] >= 1

    db = SessionLocal()
    try:
        assert db.get(CoinAccount, uid).coin_balance == 0
    finally:
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py -k "worker" -q Expected: FAIL — ModuleNotFoundError: No module named 'app.core.inactivity_reset_worker'

  • Step 3: Create the worker

Create app/core/inactivity_reset_worker.py(结构、文件锁完全仿 app/core/daily_exchange_worker.py):

"""15 天不活跃清零的进程内每日任务。

仿 daily_exchange_worker:App 启动自带,每 INACTIVITY_RESET_CHECK_INTERVAL_SEC 醒一次,
跨进北京新的一天且到达 INACTIVITY_RESET_RUN_HOUR 后跑一轮(预警 + 清零)。
- 逐用户幂等:清完余额=0 次日不再匹配;预警 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。
- 同机多进程互斥:文件锁。
- 开关:INACTIVITY_RESET_ENABLED=false 时不启动。
"""
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]:
    _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", interval)
    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
  • Step 4: Wire into lifespan

app/main.py:

(a) 导入(紧接 from app.core.daily_exchange_worker import (...) 之后,约 line 47):

from app.core.inactivity_reset_worker import (
    start_inactivity_reset_worker,
    stop_inactivity_reset_worker,
)

(b) 启动(在 daily_exchange_task = start_daily_exchange_worker() 之后,约 line 82):

    inactivity_task = start_inactivity_reset_worker()

(c) 停止(在 await stop_daily_exchange_worker(daily_exchange_task) 之后,约 line 88):

        await stop_inactivity_reset_worker(inactivity_task)
  • Step 5: Run test to verify it passes

Run: pytest tests/test_inactivity_reset.py -k "worker" -q Expected: PASS

  • Step 6: Commit
git add app/core/inactivity_reset_worker.py app/main.py tests/test_inactivity_reset.py
git commit -m "feat(welfare): 不活跃清零每日 worker + lifespan 接线(总闸默认关)"

Task 10: 重构 admin 改用共享口径(R4:单一真源)

Files:

  • Modify: app/admin/repositories/stats.py
  • Modify: app/admin/repositories/queries.py
  • Test: 现有 tests/test_admin_read.py 回归 + 追加一条

背景: 现 admin 口径散落且含 last_login_at。改造后 = 复用 activity.py(含 home_view、不含 last_login_at、以 created_at 为基线)。这会改变 admin「最近活跃/DAU」口径(登录不再计活跃、纳入 home_view),属预期变化。

  • Step 1: Write the failing test

追加到 tests/test_inactivity_reset.py(验证共享口径 = admin 会用到的排序列口径):

def test_admin_last_active_uses_shared_expr_without_login() -> None:
    """admin 用户列表的 last_active 计算与共享口径一致:登录不推进、home_view 推进。"""
    from sqlalchemy import select
    from app.admin.repositories.queries import _last_active_parts  # 改造后仍在,内部委托 activity
    db = SessionLocal()
    try:
        # 仅有旧 created_at + 很新的 last_login_at,无任何活跃事件 → last_active 应=created_at(不看登录)
        uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
        u = db.get(User, uid)
        u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc)  # 登录很新
        db.commit()
        ev_sub, eng_sub = _last_active_parts()
        dialect = db.get_bind().dialect.name
        expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
        stmt = (select(expr).select_from(User)
                .outerjoin(ev_sub, ev_sub.c.user_id == User.id)
                .outerjoin(eng_sub, eng_sub.c.user_id == User.id)
                .where(User.id == uid))
        assert activity.norm_utc(db.execute(stmt).scalar_one()) == datetime(2026, 1, 1, tzinfo=timezone.utc)
    finally:
        db.rollback()
        db.close()
  • Step 2: Run test to verify it fails

Run: pytest tests/test_inactivity_reset.py::test_admin_last_active_uses_shared_expr_without_login -q Expected: FAIL — 现 _last_active_parts() 无参、且 _last_active_expr/list_users 仍以 last_login_at 为基线,断言不成立(得到 2026-06-01)。

  • Step 3: Refactor stats.py

app/admin/repositories/stats.py,把本地事件常量改为复用共享(约 line 51-52):

from app.repositories.activity import COMPARE_START_EVENT, COUPON_START_EVENT  # noqa: F401

删除原来的 COMPARE_START_EVENT = "real_compare_start" / COUPON_START_EVENT = "real_coupon_start" 两行赋值(其它文件从 stats 导入它们的地方不受影响,因为已 re-export)。_period_active_user_ids(约 line 130)保持不变——它是"区间活跃"(含 last_login_at 落在区间),与"最近活跃排序列"是两套用途,本次不动其登录口径;仅统一事件名常量来源。

  • Step 4: Refactor queries.py

app/admin/repositories/queries.py:

(a) 顶部改为从 activity 导入常量/助手,并让 _last_active_parts / _norm_utc / _as_utc 委托共享实现:

from app.repositories.activity import (
    ACTIVE_EVENTS as _ACTIVE_EVENTS,   # noqa: F401  (= home_view + 比价 + 领券)
    as_utc as _as_utc,
    last_active_expr as _shared_last_active_expr,
    last_active_subqueries,
    norm_utc as _norm_utc,
)

删除本地重复定义:_ACTIVE_EVENTS = (...)(约 line 38)、_norm_utc(约 line 127-131)、_as_utc(约 line 625-634)。若 _as_utc_naive(约 line 1012)仍被引用,保留它并让其调用导入来的 _as_utc

(b) _last_active_parts() 改为直接委托:

def _last_active_parts():
    """(保留函数名以兼容调用点)最近活跃两个聚合子查询,委托共享口径。"""
    return last_active_subqueries(_session_of_current_call)  # 见下:改签名

说明:共享 last_active_subqueries(db) 需要 db。现 _last_active_parts() 无参(用全局 select(...) 构造,不需 session)。共享实现同样只用 select(...).subquery()、不真执行,可把 db 参数忽略/设可选。因此把共享函数签名放宽:在 app/repositories/activity.py

def last_active_subqueries(db: Session):

改为

def last_active_subqueries(db: Session | None = None):

(函数体不使用 db,只构造子查询;db 仅为语义占位)。然后 queries.py:

def _last_active_parts():
    return last_active_subqueries()

(c) list_users 里构造 last_active(约 line 199-204)改用共享 expr、基线换 User.created_at:

    ev_agg, eng_agg = _last_active_parts()
    last_active = _shared_last_active_expr(
        User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
    )

删除原先 greatest = func.greatest if ... else func.max 与手写 greatest(User.last_login_at, coalesce(..., User.last_login_at), ...) 那几行。

(d) _attach_last_active(约 line 134-168)把基线由 last_login_atcreated_at,事件集用共享常量:

    for u in users:
        candidates = [
            _norm_utc(u.created_at),
            _norm_utc(ev_map.get(u.id)),
            _norm_utc(eng_map.get(u.id)),
        ]
        u.last_active_at = max((c for c in candidates if c is not None), default=None)

并把该函数内两处 AnalyticsEvent.event.in_(_ACTIVE_EVENTS) 保持(现 _ACTIVE_EVENTS 已是导入来的三事件集,含 home_view)。

  • Step 5: Run new test + full admin regression

Run: pytest tests/test_inactivity_reset.py::test_admin_last_active_uses_shared_expr_without_login tests/test_admin_read.py -q Expected: 新用例 PASS。若 test_admin_read.py 有断言依赖旧口径(把 last_login_at 当活跃、或未含 home_view),按新口径更新其预期值(这是设计明确的口径变化,见 spec §12);非活跃口径断言应保持通过。

  • Step 6: Run the whole suite

Run: pytest -q Expected: 全绿。重点看 test_admin_*test_analytics_*。任何红都要判断是"口径预期变化需更新断言"还是"真回归需修实现"。

  • Step 7: Commit
git add app/admin/repositories/queries.py app/admin/repositories/stats.py app/repositories/activity.py tests/
git commit -m "refactor(admin): 最近活跃口径改用共享 activity 模块(移除 last_login_at,纳入 home_view)"

Task 11: 表字典文档(轻量)

Files:

  • Create: docs/database/inactivity_reset_log.md

  • Create: docs/database/inactivity_notification_log.md

  • Step 1: Write the docs

仿 docs/database/ 现有条目风格(字段表 + 用途一句话)。inactivity_reset_log:记录每次清零的清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数,供纠纷排查;与钱包流水 biz_type=inactivity_reset(ref_id=本表 id)交叉对账。inactivity_notification_log:记录每次预警的余额快照 + 档位 + 通道 + 状态,兼预警去重依据与占位 outbox。

  • Step 2: Commit
git add docs/database/inactivity_reset_log.md docs/database/inactivity_notification_log.md
git commit -m "docs(database): 新增 inactivity 两表字典条目"

Self-Review

1. Spec coverage:

  • R1 清零(15天/北京0点边界) → Task 3(reset_cutoff)+ Task 7(run_reset)+ Task 9(worker)✓
  • R2 审计(原因+清零前余额)→ Task 1(inactivity_reset_log)+ Task 7(写审计+流水 ref 交叉)✓
  • R3 预警(xx金币xx现金)→ Task 1(notification_log)+ Task 6(notifier)+ Task 8(run_warn,快照余额)✓
  • R4 活跃口径与用户管理一致 → Task 3/4(activity 共享)+ Task 10(admin 重构)✓
  • R5 完全可配置 → Task 5(天数/次数/执行点/通道)✓
  • 活跃口径 = created_at 基线 + home_view/比价/领券,不含 last_login_at → Task 3/4/10 ✓
  • 幂等/重新活跃/streak 去重 → Task 7(清完=0)/Task 8(created_at>last_active 去重)✓
  • 触发=进程内每日 worker + 总闸默认关 → Task 9 ✓

2. Placeholder scan: 无 TBD/TODO 泛化步骤;home_view 事件名以 HOME_VIEW_EVENT 常量显式占位(前端明天定名后仅改该常量,已在 Task 3 注明)。migration 的 revision/down_revision 由 autogenerate 生成(非占位)。

3. Type consistency: 跨任务一致 —— activity.reset_cutoff/as_utc/norm_utc/last_active_subqueries/last_active_expr/ACTIVE_EVENTS/ACTIVE_ENGAGE_TYPE/HOME_VIEW_EVENT;inactivity.run_once/run_reset_once/run_warn_once/clear_user/select_inactive_users/select_warn_candidates/RESET_BIZ_TYPE;notifier.get_notifier/InactivityNotifier.warn(...)->str/channel;模型 InactivityResetLog/InactivityNotificationLog 字段名与 Task 1 定义一致;wallet.grant_coins/grant_cash/grant_invite_cash(db, uid, amount, *, biz_type, ref_id, remark) 与仓库实际签名一致;get_or_create_account(db, uid, commit=False, lock=True) 一致。

已知跨仓依赖: home_view 埋点由 Android 端明天新增(spec §11);后端先以常量占位、总闸默认关,home_view 覆盖稳定后再开总闸(spec §13)。