e887a60a18
提现人工审核(提现不再即时打款): - models/wallet.py: WithdrawOrder 状态机改为 reviewing →(审核通过)→ pending → success/failed;reviewing →(驳回)→ rejected(已退款), 默认态 pending → reviewing(扣现金建单但不打款);新增 user_name 列(实名, 微信达额转账要求) - admin/routers/withdraw.py + admin/schemas/wallet.py: 运营后台审核接口(通过触发微信转账 / 驳回退款) - api/v1/wallet.py + repositories/wallet.py: 发起提现进 reviewing 态, 驳回退回现金并写 withdraw_refund - schemas/welfare.py: 提现出参增 reviewing/rejected 状态 + fail_reason 看广告每日时长限流(防刷主闸 + 次数兜底): - 新增 models/ad_watch_log.py: 按 (user_id, watch_date) 聚合当日观看秒数 - 新增 repositories/ad_watch.py: watched_seconds_today / add_watch_seconds(夹 [0, MAX_SINGLE_WATCH_SECONDS]) - api/v1/ad.py: 新增 POST /ad/watch-report(JWT 取 user, 落时长返当日累计); /ad/reward-status 增 watched_seconds_today / limit / remaining - schemas/ad.py: WatchReportIn/Out - core/rewards.py: 新增 DAILY_AD_WATCH_SECONDS_LIMIT=50 分钟(主闸)+ MAX_SINGLE_WATCH_SECONDS=120; DAILY_AD_REWARD_LIMIT 20→200 改作次数兜底(防前端少报时长绕过时长闸, 又不误伤看短广告的正常用户) - repositories/ad_reward.py + models/__init__.py: 接入新表与时长闸 alembic: withdraw_review_and_ad_watch 迁移(withdraw_order.user_name 列 + ad_watch_log 表) tests: 补 test_ad_reward / test_withdraw Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
"""看激励视频发奖 CRUD。
|
|
|
|
发奖三道闸(仿提现的资金安全思路):
|
|
1. 验签不过 → API 层直接拒,不进这里。
|
|
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
|
|
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
|
|
|
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
|
|
保证"记一笔 + 加金币"原子化。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.ad_cooldown import compute_cooldown
|
|
from app.core.rewards import (
|
|
AD_REWARD_COIN,
|
|
DAILY_AD_REWARD_LIMIT,
|
|
DAILY_AD_WATCH_SECONDS_LIMIT,
|
|
cn_today,
|
|
)
|
|
from app.repositories import wallet as crud_wallet
|
|
from app.repositories.ad_watch import watched_seconds_today
|
|
from app.models.ad_reward import AdRewardRecord
|
|
from app.models.user import User
|
|
|
|
|
|
class UnknownUserError(Exception):
|
|
"""回调里的 user_id 不存在(可能是伪造)。"""
|
|
|
|
|
|
def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
|
return db.execute(
|
|
select(AdRewardRecord).where(AdRewardRecord.trans_id == trans_id)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
|
return db.execute(
|
|
select(func.count())
|
|
.select_from(AdRewardRecord)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.reward_date == reward_date,
|
|
AdRewardRecord.status == "granted",
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
def grant_ad_reward(
|
|
db: Session,
|
|
user_id: int,
|
|
trans_id: str,
|
|
*,
|
|
coin: int = AD_REWARD_COIN,
|
|
reward_name: str | None = None,
|
|
raw: str | None = None,
|
|
) -> AdRewardRecord:
|
|
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
|
|
|
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
|
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
|
|
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
|
"""
|
|
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
if db.get(User, user_id) is None:
|
|
raise UnknownUserError
|
|
|
|
today = cn_today().isoformat()
|
|
|
|
# #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币,
|
|
# 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长绕过。
|
|
over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
|
over_count = _granted_today(db, user_id, today) >= DAILY_AD_REWARD_LIMIT
|
|
if over_time or over_count:
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
# 发金币 + 记一笔,同事务
|
|
crud_wallet.grant_coins(
|
|
db, user_id, coin,
|
|
biz_type="ad_reward", ref_id=trans_id, remark="看视频奖励金币",
|
|
)
|
|
rec = AdRewardRecord(
|
|
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
|
reward_date=today, reward_name=reward_name, raw=raw,
|
|
)
|
|
return _commit_record(db, rec, trans_id)
|
|
|
|
|
|
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
|
|
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
|
|
db.add(rec)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
existing = _find_by_trans(db, trans_id)
|
|
if existing is not None:
|
|
return existing
|
|
raise
|
|
db.refresh(rec)
|
|
return rec
|
|
|
|
|
|
def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
|
|
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
|
|
return list(
|
|
db.execute(
|
|
select(AdRewardRecord.created_at)
|
|
.where(
|
|
AdRewardRecord.user_id == user_id,
|
|
AdRewardRecord.reward_date == reward_date,
|
|
AdRewardRecord.status == "granted",
|
|
)
|
|
.order_by(AdRewardRecord.created_at.desc())
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def today_status(
|
|
db: Session, user_id: int
|
|
) -> tuple[int, int, int, int, datetime | None, int, int]:
|
|
"""客户端查"今日看广告发奖"进度。
|
|
|
|
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
|
|
今日已观看总秒数, 每日观看总时长上限(秒))。
|
|
次数维度的"本轮已看几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown];
|
|
时长维度(50 分钟主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。
|
|
"""
|
|
today = cn_today().isoformat()
|
|
granted_desc = _granted_times_today_desc(db, user_id, today)
|
|
state = compute_cooldown(granted_desc, datetime.now(timezone.utc))
|
|
return (
|
|
len(granted_desc),
|
|
DAILY_AD_REWARD_LIMIT,
|
|
AD_REWARD_COIN,
|
|
state.round_count,
|
|
state.cooldown_until,
|
|
watched_seconds_today(db, user_id, today=today),
|
|
DAILY_AD_WATCH_SECONDS_LIMIT,
|
|
)
|