diff --git a/alembic/versions/135e79414fd0_add_inactivity_tables.py b/alembic/versions/135e79414fd0_add_inactivity_tables.py new file mode 100644 index 0000000..e522bae --- /dev/null +++ b/alembic/versions/135e79414fd0_add_inactivity_tables.py @@ -0,0 +1,68 @@ +"""add inactivity tables + +Revision ID: 135e79414fd0 +Revises: comparison_llm_cost +Create Date: 2026-07-16 18:31:02.105929 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '135e79414fd0' +down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +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") diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py new file mode 100644 index 0000000..8cdaa5c --- /dev/null +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -0,0 +1,36 @@ +"""analytics_event 活跃口径复合索引 + +Revision ID: analytics_active_idx +Revises: 135e79414fd0 +Create Date: 2026-07-18 17:35:00.000000 + +给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): +activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 + +⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 +comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 +一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。 +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "analytics_active_idx" +down_revision: Union[str, Sequence[str], None] = "135e79414fd0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ix_analytics_event_active", + "analytics_event", + ["event", "page", "user_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_analytics_event_active", table_name="analytics_event") diff --git a/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py new file mode 100644 index 0000000..081f9b4 --- /dev/null +++ b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py @@ -0,0 +1,25 @@ +"""merge inactivity(analytics_active_idx) + phone_rebind_log heads + +Revision ID: merge_active_phone +Revises: analytics_active_idx, phone_rebind_log +Create Date: 2026-07-18 18:52:34.001148 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'merge_active_phone' +down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 501dba9..c9e0b74 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -11,7 +11,6 @@ from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select from sqlalchemy.orm import Session -from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT from app.core import rewards from app.core.config import settings from app.models.ad_feed_reward import AdFeedRewardRecord @@ -32,10 +31,7 @@ from app.models.wallet import ( InviteCashTransaction, WithdrawOrder, ) -from app.repositories import ad_ecpm - -# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券) -_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +from app.repositories import activity, ad_ecpm # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") @@ -88,49 +84,6 @@ def offset_paginate( return items, next_cursor, total -def _last_active_parts(): - """「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。 - - 活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at + - 发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。 - 用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan - (排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口; - 预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。 - """ - ev_agg = ( - 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_agg = ( - 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 == "claim_started", - ) - .group_by(CouponPromptEngagement.user_id) - .subquery() - ) - return ev_agg, eng_agg - - -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 _attach_last_active(db: Session, users: list[User]) -> None: """给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。 @@ -144,7 +97,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None: select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at)) .where( AnalyticsEvent.user_id.in_(uids), - AnalyticsEvent.event.in_(_ACTIVE_EVENTS), + activity.active_event_condition(), ) .group_by(AnalyticsEvent.user_id) ).all() @@ -161,9 +114,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None: ) for u in users: candidates = [ - _norm_utc(u.last_login_at), - _norm_utc(ev_map.get(u.id)), - _norm_utc(eng_map.get(u.id)), + activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃) + activity.norm_utc(ev_map.get(u.id)), + activity.norm_utc(eng_map.get(u.id)), ] u.last_active_at = max((c for c in candidates if c is not None), default=None) @@ -191,16 +144,12 @@ def list_users( (口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一, 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" - # 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max() - # 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底 - # (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数, - # offset_paginate 的 count 不受影响。 - ev_agg, eng_agg = _last_active_parts() - greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max - last_active = greatest( - User.last_login_at, - func.coalesce(ev_agg.c.last_at, User.last_login_at), - func.coalesce(eng_agg.c.last_at, User.last_login_at), + # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at + #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 + ev_agg, eng_agg = activity.last_active_subqueries(db) + last_active = activity.last_active_expr( + User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name ) stmt = ( select(User) diff --git a/app/core/config.py b/app/core/config.py index fe01e31..5e88d11 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -178,6 +178,13 @@ class Settings(BaseSettings): # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 + # === 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 唤醒间隔(秒) # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 @@ -198,6 +205,19 @@ class Settings(BaseSettings): """免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。""" return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL) + @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) + # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 diff --git a/app/core/inactivity_reset_worker.py b/app/core/inactivity_reset_worker.py new file mode 100644 index 0000000..7296d59 --- /dev/null +++ b/app/core/inactivity_reset_worker.py @@ -0,0 +1,143 @@ +"""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 diff --git a/app/integrations/notifier.py b/app/integrations/notifier.py new file mode 100644 index 0000000..5731d12 --- /dev/null +++ b/app/integrations/notifier.py @@ -0,0 +1,43 @@ +"""不活跃预警通知器(可插拔)。 + +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, + 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, + stage: int, days_until_reset: int) -> str: + logger.warning( + "[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s", + user_id, coin, 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() diff --git a/app/main.py b/app/main.py index 3b3b6ff..f99611e 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.inactivity_reset_worker import ( + start_inactivity_reset_worker, + stop_inactivity_reset_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 ( @@ -80,12 +84,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() + inactivity_task = start_inactivity_reset_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_inactivity_reset_worker(inactivity_task) await aclose_pricebot_client() logger.info("shutting down") diff --git a/app/models/__init__.py b/app/models/__init__.py index b6303b7..f2c2fc0 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -27,6 +27,10 @@ from app.models.coupon_state import ( # noqa: F401 CouponSession, ) from app.models.feedback import Feedback # noqa: F401 +from app.models.inactivity import ( # noqa: F401 + InactivityNotificationLog, + InactivityResetLog, +) from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 63110c3..21f03c5 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -15,7 +15,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func +from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -23,6 +23,12 @@ from app.db.base import Base class AnalyticsEvent(Base): __tablename__ = "analytics_event" + __table_args__ = ( + # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): + # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 + # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/models/inactivity.py b/app/models/inactivity.py new file mode 100644 index 0000000..e67fa27 --- /dev/null +++ b/app/models/inactivity.py @@ -0,0 +1,58 @@ +"""15 天不活跃清零相关表。 + +- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数, + 供纠纷排查(需求①)。清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset), + 资金流可逐笔回溯。**邀请现金是产品红线、不清零**,invite_cash_balance_cents_before 仅为清零时 + 仍保留的邀请现金快照(便于排查、非被清金额;见 wallet.CoinAccount 注释)。 +- 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"" + + +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"" diff --git a/app/repositories/activity.py b/app/repositories/activity.py new file mode 100644 index 0000000..f3e83b1 --- /dev/null +++ b/app/repositories/activity.py @@ -0,0 +1,101 @@ +"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 + +口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], 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 and_, func, or_, 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 + +# —— 活跃口径事件(与"用户管理"口径一致)—— +# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 +# active_event_condition);其余为纯 event 名。 +HOME_VIEW_EVENT = "show" +HOME_VIEW_PAGE = "home" +COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) +COUPON_START_EVENT = "real_coupon_start" # 发起领券 +# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) +ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 + + +def active_event_condition(): + """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" + return or_( + and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), + AnalyticsEvent.event.in_(ACTIVE_EVENTS), + ) + + +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)) + + +def last_active_subqueries(db: Session): + """两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、 + 最近领券发起(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), active_event_condition()) + .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), + ) diff --git a/app/repositories/inactivity.py b/app/repositories/inactivity.py new file mode 100644 index 0000000..71970cc --- /dev/null +++ b/app/repositories/inactivity.py @@ -0,0 +1,183 @@ +"""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) -> bool: + """单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清** + (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真清了(有可清余额)。""" + 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 + 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) + # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 + 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 + + +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) -> dict: + """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。 + 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。""" + 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) + return {**warn, **reset} diff --git a/docs/database/README.md b/docs/database/README.md index e173694..5f93769 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -42,6 +42,8 @@ | `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) | | `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) | | `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) | +| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) | +| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | diff --git a/docs/database/inactivity_notification_log.md b/docs/database/inactivity_notification_log.md new file mode 100644 index 0000000..e5c3b6e --- /dev/null +++ b/docs/database/inactivity_notification_log.md @@ -0,0 +1,36 @@ +# inactivity_notification_log — 不活跃清零前预警记录 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。 +- **U / D**:无(append-only)。 +- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键 | +| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) | +| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) | +| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) | +| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) | +| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) | +| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) | +| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` | +| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` | +| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- 与 `inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。 + +## 索引与约束 +- PK `id`;`ix_inactivity_notification_log_user_id`、`ix_inactivity_notification_log_created_at`。 + +## 注意 +- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。 +- **占位实现**:v1 `LogNotifier` 只 `logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。 +- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。 diff --git a/docs/database/inactivity_reset_log.md b/docs/database/inactivity_reset_log.md new file mode 100644 index 0000000..4de9578 --- /dev/null +++ b/docs/database/inactivity_reset_log.md @@ -0,0 +1,35 @@ +# inactivity_reset_log — 15 天不活跃清零审计 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()` 拿 `id` 作流水 `ref_id` 交叉链接。 +- **U / D**:无(append-only 审计)。 +- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction` 的 `ref_id` 交叉核对)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 | +| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) | +| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 | +| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 | +| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" | +| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` | +| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) | +| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` | +| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- `id` → `coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。 + +## 索引与约束 +- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。 + +## 注意 +- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。 +- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。 +- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。 diff --git a/docs/superpowers/plans/2026-07-16-inactivity-reset.md b/docs/superpowers/plans/2026-07-16-inactivity-reset.md new file mode 100644 index 0000000..94fcddd --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-inactivity-reset.md @@ -0,0 +1,1396 @@ +# 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_DAYS−1))`。 + +--- + +## File Structure + +**新增** +- `app/models/inactivity.py` — `InactivityResetLog` + `InactivityNotificationLog`(两个小关联模型同文件,仿 `wallet.py` 多模型同文件) +- `app/repositories/activity.py` — 活跃口径唯一真源(常量 + tz 助手 + cutoff + 子查询 + `last_active_expr`) +- `app/integrations/notifier.py` — `InactivityNotifier` 协议 + `LogNotifier` + `get_notifier` +- `app/repositories/inactivity.py` — 选取/清零/预警业务逻辑(纯同步) +- `app/core/inactivity_reset_worker.py` — 每日 worker(asyncio + 文件锁 + 守卫 + 启停) +- `alembic/versions/_add_inactivity_tables.py` — 建两表迁移 +- `tests/test_inactivity_reset.py` — 单测/集成 + +**改动** +- `app/models/__init__.py` — 注册两模型 +- `app/core/config.py` — `INACTIVITY_*` 配置 + `inactivity_warn_stages` 属性 +- `app/main.py` — lifespan 接线 start/stop worker +- `app/admin/repositories/queries.py`、`app/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`: + +```python +"""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`: + +```python +"""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"" + + +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"" +``` + +- [ ] **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): + +```python +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** + +```bash +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/_add_inactivity_tables.py` (由 autogenerate 生成文件名/revision) + +- [ ] **Step 1: Confirm single head** + +Run: `alembic heads` +Expected: 恰好一个 head(单行)。若多个 head,先 `alembic merge -m "merge heads" ` 再继续。 + +- [ ] **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` 改成: + +```python +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** + +```bash +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`: + +```python +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`: + +```python +"""活跃口径唯一真源: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** + +```bash +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`: + +```python +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` 末尾追加: + +```python +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** + +```bash +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** + +追加: + +```python +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.py` 的 `Settings` 类里,紧接 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600`(约 line 171)之后加: + +```python + # === 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` 附近)加解析属性: + +```python + @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** + +```bash +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** + +```python +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`: + +```python +"""不活跃预警通知器(可插拔)。 + +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** + +```bash +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** + +```python +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`: + +```python +"""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** + +```bash +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** + +```python +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` 末尾追加: + +```python +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** + +```bash +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** + +```python +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`): + +```python +"""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): + +```python +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): + +```python + inactivity_task = start_inactivity_reset_worker() +``` + +(c) 停止(在 `await stop_daily_exchange_worker(daily_exchange_task)` 之后,约 line 88): + +```python + 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** + +```bash +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 会用到的排序列口径): + +```python +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): + +```python +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` 委托共享实现: + +```python +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()` 改为直接委托: + +```python +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` 把 + +```python +def last_active_subqueries(db: Session): +``` + +改为 + +```python +def last_active_subqueries(db: Session | None = None): +``` + +(函数体不使用 db,只构造子查询;`db` 仅为语义占位)。然后 queries.py: + +```python +def _last_active_parts(): + return last_active_subqueries() +``` + +(c) `list_users` 里构造 `last_active`(约 line 199-204)改用共享 expr、基线换 `User.created_at`: + +```python + 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_at` 换 `created_at`,事件集用共享常量: + +```python + 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** + +```bash +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** + +```bash +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)。 diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md new file mode 100644 index 0000000..3df388d --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -0,0 +1,296 @@ +# 15 天不活跃自动清零(金币 + 现金)设计 + +- **日期**:2026-07-16 +- **状态**:Draft — 待评审 +- **所属**:app-server(`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖 +- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。 + +--- + +## 1. 背景与目标 + +运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求: + +1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。 +2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。 + +### 非目标(本期不做) + +- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。 +- 不改动提现(`WithdrawOrder`)流程。 +- 不新增 `User.last_active_at` 列、不改鉴权热路径。 + +--- + +## 2. 需求 + +| # | 需求 | 落地 | +|---|---|---| +| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) | +| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) | +| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7) | +| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`,admin 与 worker 共用(§4、§12) | +| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8) | + +--- + +## 3. 决策记录(来自评审问答) + +| 决策点 | 结论 | 理由 | +|---|---|---| +| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | +| **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | +| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | +| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron | +| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4) | + +### 已知取舍(可接受) + +- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。 + +--- + +## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建) + +活跃口径的**唯一真源**。app 侧模块,admin 可 import(`app.main` 不 import `app.admin`,反向允许)。 + +### 口径 + +``` +last_active = max( + User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进) + max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS, + max CouponPromptEngagement.created_at WHERE engage_type == "claim_started", +) +不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h) + last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」 + 清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点 + 应清零 ⟺ (cn_today() − last_active_date).days ≥ RESET_DAYS + ⟺ last_active < cutoff, cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1)) # 供 SQL 比较 + inactive_days = (cn_today() − last_active_date).days # 清零当日恰 = RESET_DAYS + 例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=15;1/15 及之前不清 +``` + +### 模块内容 + +- 常量: + - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 + - `ACTIVE_ENGAGE_TYPE = "claim_started"` +- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。 +- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。 +- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。 +- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() − (reset_days−1))`,供下面查询按 `last_active < cutoff` 比较。 +- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0`;**邀请现金不清、不计入候选**),返回 `(user, account, last_active, inactive_days)`。 +- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。 + +> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204`(`_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146`(`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。 + +--- + +## 5. 数据模型(2 张新表,不动 `User`) + +两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`,SQLite 兼容)。 + +### ① `inactivity_reset_log` —— 清零审计(R2) + +仿 `app/models/phone_rebind_log.py` 的简单审计表风格。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int PK autoincrement | | +| `user_id` | int, index, not null | | +| `coin_balance_before` | int, not null | 清零前金币 | +| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) | +| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) | +| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 | +| `inactive_days` | int, not null | 判定时不活跃天数 | +| `reason` | String(32), not null | 如 `"inactive_15d"` | +| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 | + +### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outbox(R3) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int PK autoincrement | | +| `user_id` | int, index, not null | | +| `stage` | int, not null | 提前天数档(如 7 / 2) | +| `inactive_days` | int, not null | 推送时不活跃天数 | +| `coin_balance` | int, not null | 推送快照:告知用户的金币数 | +| `cash_balance_cents` | int, not null | 推送快照:折算现金 | +| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 | +| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` | +| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` | +| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9) | + +> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。 + +--- + +## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建) + +**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。 + +### 调度 + +- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。 +- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。 +- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**:worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。 +- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker` 在 `app/main.py` 的接线)。 + +### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit) + +**阶段 A — 预警** +``` +for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...): + notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days) + db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status)) + db.commit() # 逐条独立 +``` + +**阶段 B — 清零**(`biz_type="inactivity_reset"`) +``` +for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS−1)) + try: + acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁 + before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) + if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue # 邀请现金不清,不算可清余额 + log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0], + cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], # 邀请现金仅快照 + last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d") + db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接 + if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + # 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。 + db.commit() + except SQLAlchemyError: + db.rollback(); stats["failed"] += 1 +``` + +- `grant_*` 负数出账、`balance_after=0`、写**两条**流水(金币 + 折算现金;**邀请现金不清**);`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。 +- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}` 并 `logger.info`。**预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零**(清零是不可逆资金操作,不能被通知故障拖住)。 + +--- + +## 7. 预警与可插拔通知器 + +`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层): + +```python +class InactivityNotifier(Protocol): + channel: str # "log" / "jpush" / "sms" + last_status: str # "placeholder" / "sent" / "failed" + def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ... +``` + +- **v1 `LogNotifier`**(`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)`,`last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。 +- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。 +- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。 +- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。 + +--- + +## 8. 配置项(`app/core/config.py`) + +``` +INACTIVITY_RESET_ENABLED = False # 总闸,默认关;灰度验证后再开 +INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天) +INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析 +INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23) +INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms +INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量) +``` + +- 清零范围(三桶)固定为常量,不做配置。 +- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k` 且 `< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYS−k)` 触发(漏跑某天时补发最紧急未推档,§9)。 +- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。 + +--- + +## 9. 幂等与重新活跃 + +- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 +- **预警去重**:`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。 +- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。 +- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。 + +--- + +## 10. 边界与安全 + +| 场景 | 处理 | +|---|---| +| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 | +| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | +| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | +| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | +| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§13) | + +--- + +## 11. 前端依赖:`home_view` 埋点(跨仓 — Android) + +- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。 +- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 +- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。 + +--- + +## 12. admin 重构范围与影响(R4) + +- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。 +- `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 +- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。 + +--- + +## 13. 灰度与上线顺序(安全优先) + +1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 +2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。 +4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。 +5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 + +--- + +## 14. 测试计划 + +- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。 +- **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 +- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 +- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 +- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。 +- **配置**:`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。 +- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。 + +--- + +## 15. 未来工作 + +- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API)/ `SmsNotifier`。 +- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。 +- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。 + +--- + +## 附:涉及文件清单 + +**新增** +- `app/repositories/activity.py` — 活跃口径唯一真源 +- `app/models/inactivity_reset_log.py` — 审计表 +- `app/models/inactivity_notification_log.py` — 预警/占位表 +- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker) +- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展) +- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移 +- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补) +- 对应 `tests/test_inactivity_reset.py` + +**改动** +- `app/models/__init__.py` — 注册两模型 +- `app/core/config.py` — `INACTIVITY_*` 配置 +- `app/main.py` — lifespan 接线 start/stop worker +- `app/admin/repositories/queries.py`、`stats.py` — 改用 `activity.py`(§12) diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py new file mode 100644 index 0000000..a1293d6 --- /dev/null +++ b/scripts/seed_inactivity_cases.py @@ -0,0 +1,139 @@ +"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。 + +用法(仓库根目录,venv 解释器): + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*) + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造 + +配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证。 +造完把 worker 打开(见 README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮。 + +⚠️ worker 清零针对**库里所有**符合条件的用户,不止 vcase*——dev 库里若有其它"老且有余额、 +无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。 +""" +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import delete, select # noqa: E402 + +from app.db.session import SessionLocal # noqa: E402 +from app.models.analytics_event import AnalyticsEvent # noqa: E402 +from app.models.inactivity import ( # noqa: E402 + InactivityNotificationLog, + InactivityResetLog, +) +from app.models.user import User # noqa: E402 +from app.models.wallet import ( # noqa: E402 + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, +) +from app.repositories import wallet as wallet_repo # noqa: E402 + +MARK = "vcase" # username 前缀,用于清理 + +# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期 +CASES = [ + ("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"), + ("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"), + ("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"), + ("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"), + ("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"), + ("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"), + ("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"), + ("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"), + ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"), + ("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"), +] + + +def _mark_uids(db) -> list[int]: + return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars()) + + +def clean(db) -> int: + uids = _mark_uids(db) + if uids: + for model in ( + InactivityResetLog, InactivityNotificationLog, + CoinTransaction, CashTransaction, InviteCashTransaction, + AnalyticsEvent, CoinAccount, + ): + db.execute(delete(model).where(model.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + return len(uids) + + +def seed(db) -> None: + now = datetime.now(UTC) + print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期") + for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1): + u = User( + phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active", + created_at=now - timedelta(days=days_ago), + last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生 + ) + db.add(u) + db.flush() # 拿自增 id + u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞 + 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 + if ev_days is not None: + db.add(AnalyticsEvent( # 首页可见 = event=show + page=home + event="show", page="home", device_id=MARK, user_id=u.id, + client_ts=0, created_at=now - timedelta(days=ev_days), + )) + db.flush() + print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}") + db.commit() + + +def check(db) -> None: + """worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。""" + rows = db.execute( + select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id) + ).all() + if not rows: + print("没有 vcase* 账号(先跑一次不带参数造号)") + return + print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警") + for uid, uname in rows: + acc = db.get(CoinAccount, uid) + bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else "—" + has_reset = db.execute( + select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1) + ).first() + stages = db.execute( + select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid) + ).scalars().all() + warn = ",".join(f"T-{s}" for s in stages) if stages else "—" + print(f"{uid:>5} {uname:<8} {bal:<24} {'有' if has_reset else '—':<5} {warn}") + + +def main() -> None: + db = SessionLocal() + try: + if "--check" in sys.argv: + check(db) + return + removed = clean(db) + if removed: + print(f"已清理上次 {removed} 个 {MARK}* 账号") + if "--clean" in sys.argv: + return + seed(db) + print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务," + "≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py new file mode 100644 index 0000000..6860d2c --- /dev/null +++ b/tests/test_inactivity_reset.py @@ -0,0 +1,416 @@ +"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import pytest +from sqlalchemy import delete, select, update + +from app.db.session import SessionLocal +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog +from app.repositories import activity + + +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() + + +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: + # 首页可见 = event=show + page=home 组合,不在纯 event 名集合里 + assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home" + assert activity.HOME_VIEW_EVENT not 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) + + +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] + + +@pytest.fixture(autouse=True) +def _isolate_inactivity_state(): + """本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚 + (commit 后的 rollback 是 no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行, + 保证每个用例干净起步。不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)。""" + db = SessionLocal() + try: + db.execute(update(CoinAccount).values( + coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0)) + for model in (AnalyticsEvent, CouponPromptEngagement, + InactivityResetLog, InactivityNotificationLog): + db.execute(delete(model)) + db.commit() + finally: + db.close() + yield + + +def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int: + """直接建一个 User + CoinAccount,created_at 可控。返回 user_id。""" + _PHONE_SEQ[0] += 1 + # 199 前缀 + 递增序号:共享测试库跨文件累积用户,别的文件用固定手机号(如 test_admin_write + # 的 13900000001..),这里用没人用的 199 段避免撞 user.phone / username 的 UNIQUE。 + u = User(phone=f"199{_PHONE_SEQ[0]:08d}", created_at=created_at, + last_login_at=created_at, status="active", + username=f"inact{_PHONE_SEQ[0]}") + 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, page=None) -> None: + db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, + created_at=when, page=page)) + + +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() + + +def test_home_signal_uses_show_event_on_home_page() -> None: + """首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。""" + from sqlalchemy import select + db = SessionLocal() + try: + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + seen = _new_user(db, created_at=base) # show/home → 活跃 + _add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home") + other = _new_user(db, created_at=base) # show/其他页 → 不算活跃 + _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon") + 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) + + def last_active(uid): + 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)) + return activity.norm_utc(db.execute(stmt).scalar_one()) + + assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算 + assert last_active(other) == base # show/其他页 不算 + finally: + db.rollback() + db.close() + + +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) + + +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, stage=7, days_until_reset=8) + assert status == "placeholder" + # 未实现通道回退 LogNotifier(占位) + assert get_notifier("jpush").channel == "log" + + +def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> 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, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home") + 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) + # 金币 + 折算现金清零;邀请现金是产品红线,原封不动(见 wallet.CoinAccount 注释) + assert (acc.coin_balance, acc.cash_balance_cents) == (0, 0) + assert acc.invite_cash_balance_cents == 300 + 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")).first() is None + + # 活跃用户不动;再跑一次幂等(coin+cash 已 0、邀请现金不算候选 → 不再匹配) + 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() + + +def test_user_with_only_invite_cash_is_not_cleared() -> None: + """只有邀请现金余额的久不活跃用户:邀请现金是产品红线,不清 → 根本不该被选中。""" + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + uid = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), + coin=0, cash=0, invite=500) + db.commit() + stats = inactivity.run_reset_once(db, reset_days=15, today=today) + assert stats["cleared"] == 0 + assert db.get(CoinAccount, uid).invite_cash_balance_cents == 500 # 原封不动 + finally: + db.rollback() + db.close() + + +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.models.wallet import CoinAccount + 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 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警不动钱 + finally: + db.rollback() + db.close() + + +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() + + +def test_admin_list_users_last_active_ignores_login() -> None: + """admin 用户列表 last_active_at 改用共享口径:登录不算活跃(baseline=created_at)、只认活跃事件。""" + from app.admin.repositories import queries + + db = SessionLocal() + try: + created = datetime(2026, 1, 1, tzinfo=timezone.utc) + uid = _new_user(db, created_at=created) + u = db.get(User, uid) + u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc) # 登录很新、但无任何活跃事件 + db.commit() + phone = db.get(User, uid).phone + users, _cursor, _total = queries.list_users(db, phone=phone) + item = next(x for x in users if x.id == uid) + assert activity.norm_utc(item.last_active_at) == created # 登录不算 → last_active=created_at + finally: + db.rollback() + db.close() + + +def test_run_warn_isolates_notifier_failure_and_does_not_block_reset() -> None: + """单用户通知器抛错:预警计 warn_failed、不外抛,且清零(reset)照常执行。""" + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + class BoomNotifier: + channel = "log" + + def warn(self, *, user_id, coin, cash_cents, stage, days_until_reset) -> str: + raise RuntimeError("push service down") + + 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=BoomNotifier(), reset_days=15, + warn_stages=[7, 2], today=today) + assert stats["warned"] == 0 and stats["warn_failed"] >= 1 # 预警失败被隔离 + assert stats["cleared"] == 1 # 关键:清零没被阻塞 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警用户不动钱 + # 预警失败已回滚,不留半条 notification_log + from sqlalchemy import select + assert db.execute(select(InactivityNotificationLog).where( + InactivityNotificationLog.user_id == warn_uid)).first() is None + finally: + db.rollback() + db.close() + + +def test_run_once_reset_runs_even_if_warn_phase_throws(monkeypatch) -> None: + """预警整段异常(如候选查询失败)也绝不阻塞清零。""" + from app.integrations.notifier import LogNotifier + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + def boom(*a, **k): + raise RuntimeError("warn phase blew up") + + monkeypatch.setattr(inactivity, "run_warn_once", boom) + + db = SessionLocal() + try: + today = date(2026, 2, 1) + clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) + db.commit() + stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, + warn_stages=[7, 2], today=today) + assert stats["cleared"] == 1 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + finally: + db.rollback() + db.close()