From 0ec6b2bc56db0f2a6bfb36cc9378616b4f2a52c8 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 16 Jul 2026 18:23:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(welfare):=20inactivity=5Freset=5Flog=20+?= =?UTF-8?q?=20inactivity=5Fnotification=5Flog=20=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/__init__.py | 4 +++ app/models/inactivity.py | 56 ++++++++++++++++++++++++++++++++++ tests/test_inactivity_reset.py | 33 ++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 app/models/inactivity.py create mode 100644 tests/test_inactivity_reset.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 694aa00..f92eaf8 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/inactivity.py b/app/models/inactivity.py new file mode 100644 index 0000000..343a785 --- /dev/null +++ b/app/models/inactivity.py @@ -0,0 +1,56 @@ +"""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"" diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py new file mode 100644 index 0000000..9a07d5e --- /dev/null +++ b/tests/test_inactivity_reset.py @@ -0,0 +1,33 @@ +"""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()