docs(welfare): 15天不活跃清零金币/现金 设计文档(spec) (#144)

修改INACTIVITY_RESET_ENABLED语义,为false时清空金币操作只记录审计日志,不执行操作。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
2026-07-19 00:14:03 +08:00
parent a86688ccfb
commit 130a7dff29
5 changed files with 119 additions and 38 deletions
+36 -21
View File
@@ -70,13 +70,24 @@ def select_inactive_users(db: Session, *, cutoff: datetime):
return db.execute(stmt).all()
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool:
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int,
reason: str, dry_run: bool = False) -> bool:
"""单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清**
(产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真了(有可清余额)。"""
(产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真处理了(有可清余额)。
dry_run=True:**只写审计名单、不动钱不写流水**(灰度看名单)。按 streak 去重——本 streak
已记过(reset_at > last_active)就跳,避免 worker 每日重复记。"""
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
if dry_run and db.execute(
select(InactivityResetLog.id).where(
InactivityResetLog.user_id == user_id,
InactivityResetLog.reset_at > activity.as_utc(last_active),
).limit(1)
).first():
return False # dry-run:本 streak 已记过审计,不重复记
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),
@@ -84,28 +95,29 @@ def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_day
)
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)刻意不动:两本账物理隔离、邀请金是产品红线。
if not dry_run: # dry-run 只记审计名单,不真出账
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,失败隔离。"""
def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict:
"""扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。"""
stats = {"scanned": 0, "cleared": 0, "failed": 0}
cutoff = activity.reset_cutoff(reset_days, today)
reason = f"inactive_{reset_days}d"
reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "")
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):
inactive_days=idays, reason=reason, dry_run=dry_run):
stats["cleared"] += 1
except SQLAlchemyError:
db.rollback()
@@ -170,14 +182,17 @@ def run_warn_once(db: Session, notifier: InactivityNotifier, *,
def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int,
warn_stages: list[int], today: date) -> dict:
warn_stages: list[int], today: date, dry_run: bool = False) -> 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)
预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。
dry_run=True(灰度默认):只记审计名单、不清、**也不预警**(不通知一个不会发生的清零)。"""
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not dry_run:
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, dry_run=dry_run)
return {**warn, **reset}