feat(welfare): 提现人工审核流程 + 看广告每日时长限流(防刷主闸)
提现人工审核(提现不再即时打款): - 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>
This commit is contained in:
+36
-2
@@ -21,12 +21,15 @@ from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
PangleCallbackOut,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
WatchReportOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.ad")
|
||||
@@ -91,7 +94,8 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
|
||||
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
||||
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
|
||||
return AdRewardStatusOut(
|
||||
used_today=used,
|
||||
daily_limit=limit,
|
||||
@@ -99,6 +103,35 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
coin_per_ad=coin_per,
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
watched_seconds_today=watched_sec,
|
||||
watch_seconds_limit=watch_limit,
|
||||
watch_seconds_remaining=max(0, watch_limit - watched_sec),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
||||
)
|
||||
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
||||
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
||||
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
||||
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
||||
"""
|
||||
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
||||
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
logger.info(
|
||||
"ad watch report user_id=%d +%ds total=%d/%d",
|
||||
user.id, payload.seconds, total, limit,
|
||||
)
|
||||
return WatchReportOut(
|
||||
watched_seconds_today=total,
|
||||
watch_seconds_limit=limit,
|
||||
watch_seconds_remaining=max(0, limit - total),
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +184,8 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
||||
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||
return TestGrantOut(
|
||||
granted=(rec.status == "granted"),
|
||||
|
||||
+13
-5
@@ -170,10 +170,11 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.post(
|
||||
"/withdraw",
|
||||
response_model=WithdrawResultOut,
|
||||
summary="发起提现到微信零钱",
|
||||
summary="发起提现(扣款建单,待人工审核;审核通过后才打款)",
|
||||
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
|
||||
)
|
||||
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
||||
if not settings.wxpay_configured:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
try:
|
||||
@@ -189,12 +190,14 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
|
||||
except crud_wallet.WithdrawTransferError as e:
|
||||
# 转账调用失败,余额已退回
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"wechat transfer failed: {e}") from e
|
||||
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
|
||||
logger.info(
|
||||
"withdraw submitted user_id=%d cents=%d bill=%s status=%s(待审核)",
|
||||
user.id, req.amount_cents, order.out_bill_no, order.status,
|
||||
)
|
||||
# 此刻 status=reviewing,尚未打款 → 无 package_info;App 据 status 提示"已提交,等待审核"。
|
||||
# 审核通过后客户端轮询 /withdraw/status 拿到 package_info(若需确认)再拉起微信确认页。
|
||||
return WithdrawResultOut(
|
||||
out_bill_no=order.out_bill_no,
|
||||
status=order.status,
|
||||
@@ -223,6 +226,11 @@ def withdraw_status(
|
||||
status=order.status,
|
||||
wechat_state=order.wechat_state,
|
||||
amount_cents=order.amount_cents,
|
||||
fail_reason=order.fail_reason,
|
||||
# 审核通过后 status=pending 且可能带 package_info(WAIT_USER_CONFIRM):供 App 拉起微信确认页
|
||||
package_info=order.package_info,
|
||||
mch_id=settings.WXPAY_MCH_ID if order.package_info else None,
|
||||
app_id=settings.WECHAT_APP_ID if order.package_info else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user