Compare commits

...

1 Commits

Author SHA1 Message Date
no_gen_mu f7fc4af17c feat(wallet): 连续15天无活跃账户金币+现金自动清零
新增进程内定时任务:每天北京0点后扫描,对连续无活跃满15整天(既没发起比价、
也没发起领券)的用户,清空金币与金币兑换现金;单纯登录不算活跃,邀请奖励金物理
隔离不动,每笔清零写 inactive_clear 流水可逐笔回溯。

- 活跃口径=发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started
  (登录 last_login_at 不算);新用户靠 created_at 兜底(注册未满15天豁免,不会刚注册就被清)
- worker 照 daily_exchange_worker 结构:文件锁同机互斥 + 北京日切 + 启动补跑
- 只扫有余额账户,清零走 grant_coins/grant_cash 负数入账 → 天然幂等
- 循环内读实时余额 + 行锁清零防并发;days<1 钳位防误配全员清空
- 新增 INACTIVE_CLEAR_ENABLED/DAYS/CHECK_INTERVAL_SEC 三开关(默认开,15天)
- 复用现有表无需迁移

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:45:20 +08:00
4 changed files with 315 additions and 0 deletions
+8
View File
@@ -169,6 +169,14 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 连续 N 天无活跃(无比价 / 领券,登录不算)账户金币+现金清零:进程内 worker
# app.core.inactive_clear_worker 跨北京 0 点跑一轮 inactive_clear.clear_inactive_accounts。
# 邀请奖励金物理隔离,不在清空范围。⚠️ 不可逆批量资金操作,口径见 repositories/inactive_clear。
# 运营要临时停在 .env 置 false(worker 不启动)。
INACTIVE_CLEAR_ENABLED: bool = True
# 连续无活跃**满这么多整天之后**才清:15 → 最后活跃在 15 天前当天仍保留,第 16 天起清。
INACTIVE_CLEAR_DAYS: int = 15
INACTIVE_CLEAR_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+132
View File
@@ -0,0 +1,132 @@
"""连续 N 天无活跃账户金币 / 现金清零的进程内定时任务。
结构同 app.core.daily_exchange_worker:每 `INACTIVE_CLEAR_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天(0 点后)就跑一轮 inactive_clear.clear_inactive_accounts。
健壮性:
- **天然幂等**:只清有余额的账户,清完余额=0,下一轮 / 重启 / 多次唤醒都不重复清。
- **当天首跑即补**:进程起来时若当天还没跑过,立即跑一轮。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
- **开关**:settings.INACTIVE_CLEAR_ENABLED=false 时不启动。
⚠️ 这是不可逆的批量资金操作(清空用户金币 + 金币现金,邀请金除外)。活跃口径与清零
细节见 app.repositories.inactive_clear。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import inactive_clear as inactive_clear_repo
logger = logging.getLogger("shagua.inactive_clear")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactive_clear.lock"
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _clear_once() -> dict:
with SessionLocal() as db:
return inactive_clear_repo.clear_inactive_accounts(
db, days=settings.INACTIVE_CLEAR_DAYS
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVE_CLEAR_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("inactive-clear skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactive-clear worker started interval=%ss days=%s",
interval,
settings.INACTIVE_CLEAR_DAYS,
)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
if last_run != today:
result = await asyncio.to_thread(_clear_once)
last_run = today
logger.info("inactive-clear done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactive-clear db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactive-clear unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactive-clear worker stopped")
raise
def start_inactive_clear_worker() -> asyncio.Task | None:
if not settings.INACTIVE_CLEAR_ENABLED:
logger.info("inactive-clear disabled (INACTIVE_CLEAR_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="inactive-clear")
async def stop_inactive_clear_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+7
View File
@@ -49,6 +49,10 @@ from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.inactive_clear_worker import (
start_inactive_clear_worker,
stop_inactive_clear_worker,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
@@ -74,18 +78,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
from app.utils import geo
geo.ensure_loaded()
except Exception: # noqa: BLE001
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
inactive_clear_task = start_inactive_clear_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_inactive_clear_worker(inactive_clear_task)
await aclose_pricebot_client()
logger.info("shutting down")
+168
View File
@@ -0,0 +1,168 @@
"""连续 N 天无活跃账户的金币 / 现金清零。
规则(2026-07-15 产品定):某用户连续 `days` 天(默认 15)既没发起比价、也没发起领券
→ 判定为「流失」,清空其金币余额(coin_balance)与金币兑换现金(cash_balance_cents)。
**单纯登录(打开 App)不算活跃**——光登录、不用比价/领券的用户视为流失照清。
**邀请奖励金(invite_cash_balance_cents)物理隔离,不在清空范围**(产品红线,见
models/wallet.py CoinAccount 注释);total_coin_earned(历史累计赚取,只增不减)也不动。
活跃口径:发起比价 real_compare_start + 发起领券 real_coupon_start / claim_started
两路并集(**不含登录 last_login_at**)。⚠️与 admin 大盘 DAU / 后台「最近活跃」列**不同**
——那两处含登录,此处刻意去掉,只认真正用了核心功能;若两处口径要同步须留意此差异。
新用户保护:从没发起过比价/领券的用户永远不在活跃集合,故用**注册时间兜底**——注册未满
days 天(created_at 在 today-days 之后)的用户豁免清零,新用户有 days 天宽限,不会刚注册
(有礼金)就被清。(登录已不算活跃,故不能再靠 last_login 兜底新用户。)
清零走 wallet.grant_coins / grant_cash(负数出账入口):更新余额快照 + 写一笔
biz_type=inactive_clear 的流水(带 balance_after),可逐笔回溯 / 人工恢复;grant_coins
对负数不累加 total_coin_earned。
"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories.wallet import get_or_create_account, grant_cash, grant_coins
CLEAR_BIZ_TYPE = "inactive_clear"
# 「活跃」计入的埋点事件:发起比价 + 发起领券(登录不算)
_ACTIVE_EVENTS = ("real_compare_start", "real_coupon_start")
def _cn_date_start_utc(d: date) -> datetime:
"""北京自然日 d 的 00:00 → UTC aware 下界。
analytics_event.created_at / user.created_at 存 UTC(见各自模型),须用 UTC 边界比较;
CN_TZ 是固定 +8 偏移(rewards.CN_TZ),无 DST 歧义。
"""
return datetime(d.year, d.month, d.day, tzinfo=rewards.CN_TZ).astimezone(UTC)
def _active_user_ids_since(db: Session, since_cn: date) -> set[int]:
"""北京自然日 since_cn(含)当天 0 点起 **发起过比价 / 领券** 的 user_id 集合。
**登录不算活跃**(产品定):只认核心功能行为。两路并集:
- 发起比价 / 领券:analytics_event.event in _ACTIVE_EVENTS 且 created_at >= since(UTC 边界)
- 发起领券:coupon_prompt_engagement.engage_type=claim_started 且 engage_date >= since(北京日列)
"""
since_utc = _cn_date_start_utc(since_cn)
ids: set[int] = set()
ids.update(
uid
for uid in db.execute(
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
AnalyticsEvent.created_at >= since_utc,
)
).scalars()
if uid is not None
)
ids.update(
uid
for uid in db.execute(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
CouponPromptEngagement.engage_date >= since_cn,
)
).scalars()
if uid is not None
)
return ids
def _new_user_ids_since(db: Session, since_cn: date) -> set[int]:
"""注册时间 >= since_cn 北京日 0 点的 user_id——注册未满 days 天的新用户,豁免清零。"""
since_utc = _cn_date_start_utc(since_cn)
return set(db.execute(select(User.id).where(User.created_at >= since_utc)).scalars())
def clear_inactive_accounts(db: Session, *, days: int, dry_run: bool = False) -> dict:
"""连续无活跃**满 `days` 整天之后**把用户金币 + 金币现金清零(邀请金不动),逐用户独立事务。
- 活跃 = 发起比价 / 发起领券(**登录不算**)。last_active >= today-days 视为活跃;连续无
比价无领券满 days 天(且注册也满 days 天)才清。days=15 → 最后一次比价/领券在 15 天前
当天仍留、第 16 天起清。today 一律北京时(rewards.cn_today())。
- 新用户保护:注册未满 days 天(created_at 在 today-days 之后)豁免,不会刚注册就被清。
- 只扫描有余额(coin_balance>0 或 cash_balance_cents>0)的账户:清零后余额=0,下一轮
自然跳过 → 天然幂等,重启 / 多次唤醒 / 补跑都安全。
- 逐用户独立事务:单用户异常 rollback 不影响其他人。
- dry_run=True:只统计不写库(供运营上线前预演:看会清哪些人、清多少)。
返回统计 dict(scanned/cleared/skipped_active/skipped_new_user/coin_cleared/
cents_cleared/failed)。
"""
# days<1 会把 active_since 推到未来 → 全员判流失清空(灾难);防御性钳到 >=1。
days = max(1, days)
today = rewards.cn_today()
# 连续无活跃满 days 整天之后才清:active_since=today-days,活跃/新用户都以此为边界。
active_since = today - timedelta(days=days)
active_ids = _active_user_ids_since(db, active_since)
new_user_ids = _new_user_ids_since(db, active_since)
# 只取候选 user_id;清零金额在循环内读**实时**余额(不用此处快照)——活跃集合算完到逐行
# 清零之间余额可能变(如并发看广告发币),用快照 grant 会清不干净、balance_after≠0。
candidate_ids = (
db.execute(
select(CoinAccount.user_id).where(
or_(CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0)
)
)
.scalars()
.all()
)
stats = {
"scanned": 0,
"cleared": 0,
"skipped_active": 0,
"skipped_new_user": 0,
"coin_cleared": 0,
"cents_cleared": 0,
"failed": 0,
}
remark = f"连续{days}天无活跃清零"
for user_id in candidate_ids:
stats["scanned"] += 1
if user_id in active_ids:
stats["skipped_active"] += 1
continue
if user_id in new_user_ids: # 注册未满 days 天,新用户宽限
stats["skipped_new_user"] += 1
continue
if dry_run:
acc = db.get(CoinAccount, user_id)
if acc is not None:
stats["cleared"] += 1
stats["coin_cleared"] += acc.coin_balance
stats["cents_cleared"] += acc.cash_balance_cents
continue
try:
# lock=True 对该账户行加 FOR UPDATE(PG 生效,SQLite no-op),读-清-写串行化防并发
acc = get_or_create_account(db, user_id, commit=False, lock=True)
coin, cents = acc.coin_balance, acc.cash_balance_cents
if coin <= 0 and cents <= 0:
continue # 快照后被并发清空 / 变动,无需再清
if coin > 0:
grant_coins(db, user_id, -coin, biz_type=CLEAR_BIZ_TYPE, remark=remark)
if cents > 0:
grant_cash(db, user_id, -cents, biz_type=CLEAR_BIZ_TYPE, remark=remark)
db.commit()
stats["cleared"] += 1
stats["coin_cleared"] += coin
stats["cents_cleared"] += cents
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
db.rollback()
stats["failed"] += 1
return stats