Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7fc4af17c |
@@ -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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user