feat(welfare): 提现人工审核流程 + 看广告每日时长限流(防刷主闸) (#15)
提现人工审核(提现不再即时打款): - 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> --------- Co-authored-by: pure <pure@192.168.0.104> Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""withdraw_order.user_name 列 + ad_watch_log 表
|
||||
|
||||
提现审核功能:WithdrawOrder 加 user_name(审核异步打款时传给微信的实名,达额转账要求)。
|
||||
看广告时长防刷:新增 ad_watch_log 表(前端 onAdClose 上报观看秒数,按 (user_id, watch_date)
|
||||
聚合做"每天 50 分钟"硬上限)。
|
||||
|
||||
Revision ID: withdraw_review_ad_watch
|
||||
Revises: ebb6af5c0b56
|
||||
Create Date: 2026-06-05 12:00:00.000000
|
||||
|
||||
注:本迁移原 down_revision=ad60a1b2c3d4(分支创建时的 head);rebase 合 main 后 main 侧已新增
|
||||
app_config / price_report 等迁移并 merge 出 head=ebb6af5c0b56,故改挂到其上,保持单 head(本迁移
|
||||
与那些表互不相干,叠加顺序无影响)。
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'withdraw_review_ad_watch'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ebb6af5c0b56'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ① 提现单加实名列:审核通过后异步打款时传给微信(达额转账要求),发起提现时存下
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('user_name', sa.String(length=64), nullable=True))
|
||||
|
||||
# ② 看广告观看时长表:前端上报观看秒数,(user_id, watch_date) 聚合做每日 50 分钟防刷主闸
|
||||
op.create_table(
|
||||
'ad_watch_log',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('watch_seconds', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('watch_date', sa.String(length=10), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_watch_date'), ['watch_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_watch_log_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_watch_date'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_watch_log_user_id'))
|
||||
op.drop_table('ad_watch_log')
|
||||
|
||||
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
|
||||
batch_op.drop_column('user_name')
|
||||
@@ -14,7 +14,7 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut, WithdrawRejectRequest
|
||||
from app.integrations import wxpay
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import wallet as wallet_repo
|
||||
@@ -83,3 +83,58 @@ def refresh_withdraw(
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(refreshed)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/approve", response_model=WithdrawOrderOut, summary="审核通过(发起微信打款)")
|
||||
def approve_withdraw_order(
|
||||
out_bill_no: str,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawOrderOut:
|
||||
"""审核通过 → 调微信转账。仅 reviewing 单可通过(非 reviewing 返 409,防重复打款)。
|
||||
|
||||
打款的钱逻辑(转账/查单/退款/幂等)复用 wallet_repo.approve_withdraw → execute_withdraw_transfer;
|
||||
返回的 order 可能是 pending(在途/待用户确认)/success/failed(打款失败已退),admin 前端按 status 展示。
|
||||
"""
|
||||
try:
|
||||
order = wallet_repo.approve_withdraw(db, out_bill_no)
|
||||
except wallet_repo.WithdrawOrderNotFound as e:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在") from e
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
except wxpay.WxPayNotConfiguredError as e:
|
||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.approve", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={
|
||||
"status": order.status,
|
||||
"wechat_state": order.wechat_state,
|
||||
"amount_cents": order.amount_cents,
|
||||
},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(order)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/reject", response_model=WithdrawOrderOut, summary="审核拒绝(退回现金)")
|
||||
def reject_withdraw_order(
|
||||
out_bill_no: str,
|
||||
body: WithdrawRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawOrderOut:
|
||||
"""审核拒绝 → 退回用户现金 + 置 rejected,理由写入 fail_reason(用户可见)。仅 reviewing 单可拒。"""
|
||||
try:
|
||||
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
|
||||
except wallet_repo.WithdrawOrderNotFound as e:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在") from e
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.reject", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={"reason": body.reason, "amount_cents": order.amount_cents},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(order)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CoinTxnOut(BaseModel):
|
||||
@@ -39,6 +39,7 @@ class WithdrawOrderOut(BaseModel):
|
||||
user_id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
user_name: str | None = None # 提现实名(审核核对 + 打款用)
|
||||
status: str
|
||||
wechat_state: str | None = None
|
||||
transfer_bill_no: str | None = None
|
||||
@@ -50,3 +51,9 @@ class WithdrawOrderOut(BaseModel):
|
||||
class ReconcileResult(BaseModel):
|
||||
checked: int
|
||||
resolved: int
|
||||
|
||||
|
||||
class WithdrawRejectRequest(BaseModel):
|
||||
reason: str = Field(
|
||||
..., min_length=1, max_length=200, description="拒绝理由(写入 fail_reason,用户可见)"
|
||||
)
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+14
-3
@@ -82,9 +82,20 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
AD_REWARD_COIN: int = 666
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 20
|
||||
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。
|
||||
# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数",
|
||||
# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次,
|
||||
# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。
|
||||
# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。
|
||||
DAILY_AD_REWARD_LIMIT: int = 200
|
||||
|
||||
# ===== 看激励视频每日总时长上限(防刷主闸)=====
|
||||
# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看
|
||||
# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面
|
||||
# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟
|
||||
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
|
||||
MAX_SINGLE_WATCH_SECONDS: int = 120
|
||||
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
|
||||
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
|
||||
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""看激励视频观看时长记录(每日总时长防刷)。
|
||||
|
||||
每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数。按 (user_id, watch_date) 聚合
|
||||
SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 /
|
||||
不给看,见 core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)。
|
||||
|
||||
这是看广告的第三条数据流,与另两条并列、互不关联:
|
||||
- ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端,有 trans_id);
|
||||
- ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端,有 ecpm);
|
||||
- ad_watch_log(本表):客户端上报观看时长(前端,有 watch_seconds)。
|
||||
前端上报的时长不可信 → 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT)
|
||||
做"次数兜底",前端少报时长也刷不过次数闸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdWatchLog(Base):
|
||||
__tablename__ = "ad_watch_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 本次激励视频实际观看秒数(前端 onAdShow→onAdClose);服务端已夹 [0, MAX_SINGLE_WATCH_SECONDS]
|
||||
watch_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"当日总时长"聚合(不在 SQL 里跨时区比较)
|
||||
watch_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdWatchLog user_id={self.user_id} sec={self.watch_seconds} {self.watch_date}>"
|
||||
+11
-6
@@ -65,11 +65,14 @@ class CoinTransaction(Base):
|
||||
|
||||
|
||||
class WithdrawOrder(Base):
|
||||
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
|
||||
"""提现单(现金 → 微信零钱)。状态机:
|
||||
reviewing →(管理员通过)→ pending → success / failed
|
||||
reviewing →(管理员拒绝)→ rejected(已退款)
|
||||
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
|
||||
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
|
||||
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态。
|
||||
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务,初始 reviewing(待人工审核,
|
||||
**不打款**);管理员审核通过才调微信转账(execute_withdraw_transfer)→ pending,拒绝则退回
|
||||
现金 + 置 rejected。微信侧失败/取消时退回现金并写 cash_transaction(withdraw_refund) → failed。
|
||||
wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED ...)。
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
@@ -81,8 +84,10 @@ class WithdrawOrder(Base):
|
||||
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
|
||||
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 归一化状态:pending / success / failed
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
|
||||
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="reviewing")
|
||||
# 微信侧原始状态
|
||||
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 微信转账单号
|
||||
|
||||
@@ -18,10 +18,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.ad_cooldown import compute_cooldown
|
||||
from app.core.rewards import cn_today
|
||||
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.repositories.ad_watch import watched_seconds_today
|
||||
|
||||
|
||||
class UnknownUserError(Exception):
|
||||
@@ -71,8 +72,12 @@ def grant_ad_reward(
|
||||
|
||||
today = cn_today().isoformat()
|
||||
|
||||
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
# #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币,
|
||||
# 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长
|
||||
# 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。
|
||||
over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
|
||||
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,
|
||||
@@ -126,12 +131,14 @@ def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> li
|
||||
|
||||
def today_status(
|
||||
db: Session, user_id: int
|
||||
) -> tuple[int, int, int, int, datetime | None]:
|
||||
) -> tuple[int, int, int, int, datetime | None, int, int]:
|
||||
"""客户端查"今日看广告发奖"进度。
|
||||
|
||||
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
|
||||
上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);冷却策略判断委托
|
||||
[app.core.ad_cooldown.compute_cooldown](纯函数)。
|
||||
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
|
||||
今日已观看总秒数, 每日观看总时长上限(秒))。
|
||||
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
|
||||
几次 + 冷却到几点"委托 [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)
|
||||
@@ -147,4 +154,6 @@ def today_status(
|
||||
rewards.get_ad_reward_coin(db),
|
||||
state.round_count,
|
||||
state.cooldown_until,
|
||||
watched_seconds_today(db, user_id, today=today),
|
||||
DAILY_AD_WATCH_SECONDS_LIMIT,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""看激励视频观看时长 CRUD(每日总时长防刷)。
|
||||
|
||||
前端 onAdClose 上报本次观看秒数 → add_watch_seconds 落库 + 返回当日累计;
|
||||
watched_seconds_today 给"时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限。
|
||||
鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import MAX_SINGLE_WATCH_SECONDS, cn_today
|
||||
from app.models.ad_watch_log import AdWatchLog
|
||||
|
||||
|
||||
def watched_seconds_today(db: Session, user_id: int, *, today: str | None = None) -> int:
|
||||
"""该用户今日(北京时间)累计观看秒数。coalesce 防无记录时 SUM 返 None。"""
|
||||
d = today or cn_today().isoformat()
|
||||
return db.execute(
|
||||
select(func.coalesce(func.sum(AdWatchLog.watch_seconds), 0)).where(
|
||||
AdWatchLog.user_id == user_id,
|
||||
AdWatchLog.watch_date == d,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def add_watch_seconds(db: Session, user_id: int, seconds: int) -> int:
|
||||
"""记一次观看时长,返回当日累计秒数。
|
||||
|
||||
seconds 夹 [0, MAX_SINGLE_WATCH_SECONDS] 防前端报异常大值(刷时长无意义,但夹紧更稳)。
|
||||
"""
|
||||
clamped = max(0, min(int(seconds), MAX_SINGLE_WATCH_SECONDS))
|
||||
today = cn_today().isoformat()
|
||||
db.add(AdWatchLog(user_id=user_id, watch_seconds=clamped, watch_date=today))
|
||||
db.commit()
|
||||
return watched_seconds_today(db, user_id, today=today)
|
||||
+92
-22
@@ -57,6 +57,10 @@ class WithdrawOrderNotFound(Exception):
|
||||
"""提现单不存在。"""
|
||||
|
||||
|
||||
class WithdrawNotReviewable(Exception):
|
||||
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
|
||||
|
||||
|
||||
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
|
||||
"""取用户金币账户,不存在则建一个空账户。"""
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
@@ -254,10 +258,18 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
return bal
|
||||
|
||||
|
||||
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
|
||||
if order.status == "failed":
|
||||
return # 防重复退款(并发/对账与查单同时触发)
|
||||
def _refund_withdraw(
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
|
||||
) -> None:
|
||||
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
|
||||
|
||||
final_status:
|
||||
- "failed" (默认):微信转账失败/取消 → 自动退款
|
||||
- "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
|
||||
两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
|
||||
"""
|
||||
if order.status in ("failed", "rejected"):
|
||||
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents)
|
||||
db.add(
|
||||
CashTransaction(
|
||||
@@ -266,10 +278,11 @@ def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
|
||||
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
||||
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
|
||||
)
|
||||
)
|
||||
order.status = "failed"
|
||||
order.status = final_status
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
|
||||
@@ -327,22 +340,26 @@ def create_withdraw(
|
||||
user_name: str | None = None,
|
||||
out_bill_no: str | None = None,
|
||||
) -> WithdrawOrder:
|
||||
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)。
|
||||
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
||||
|
||||
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
|
||||
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态。
|
||||
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
|
||||
返回提现单(可能含 package_info 供 App 拉起确认页)。
|
||||
打款推迟到管理员在 admin 后台审核通过(approve_withdraw → execute_withdraw_transfer)才发起;
|
||||
拒绝则退回现金(reject_withdraw)。
|
||||
#1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
|
||||
重复发起多笔提现(审核拒绝再退回)。
|
||||
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
||||
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
||||
"""
|
||||
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
raise InvalidWithdrawAmountError
|
||||
|
||||
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
||||
user = db.get(User, user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
raise WechatNotBoundError
|
||||
|
||||
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
|
||||
# #2 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
|
||||
# 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
|
||||
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
|
||||
existing = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
@@ -350,8 +367,6 @@ def create_withdraw(
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if existing.status == "pending":
|
||||
return refresh_withdraw_status(db, user_id, out_bill_no)
|
||||
return existing
|
||||
else:
|
||||
out_bill_no = uuid.uuid4().hex
|
||||
@@ -374,32 +389,53 @@ def create_withdraw(
|
||||
balance_after_cents=bal,
|
||||
biz_type="withdraw",
|
||||
ref_id=out_bill_no,
|
||||
remark="提现到微信零钱",
|
||||
remark="提现到微信零钱(待审核)",
|
||||
)
|
||||
)
|
||||
order = WithdrawOrder(
|
||||
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
|
||||
user_id=user_id,
|
||||
out_bill_no=out_bill_no,
|
||||
amount_cents=amount_cents,
|
||||
user_name=user_name,
|
||||
status="reviewing",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order # 待管理员审核;**不在此处打款**
|
||||
|
||||
|
||||
def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
|
||||
"""审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。
|
||||
|
||||
流程:reviewing → 置 pending → 调微信转账 → SUCCESS=success / 失败/取消=退款 failed /
|
||||
结果不明=先查单再决定。**不抛异常**(admin 调用方按 order.status 判断结果)。
|
||||
用户在审核期间解绑微信 → 退款 failed(打不了款)。
|
||||
可能返回 package_info(WAIT_USER_CONFIRM 场景:需用户在 App 端确认页确认后才真正到账)。
|
||||
"""
|
||||
user = db.get(User, order.user_id)
|
||||
openid = user.wechat_openid if user else None
|
||||
if not openid:
|
||||
_refund_withdraw(db, order, reason="用户已解绑微信,无法打款")
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
order.status = "pending" # 进入打款在途(转账调用前崩溃留 pending,交对账兜底)
|
||||
db.commit()
|
||||
|
||||
# 2) 调微信转账;结果不明先查单再决定(#3)
|
||||
try:
|
||||
result = wxpay.create_transfer(openid, amount_cents, out_bill_no, user_name=user_name)
|
||||
result = wxpay.create_transfer(
|
||||
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认
|
||||
_settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(str(e)) from e
|
||||
return order
|
||||
|
||||
if result["status_code"] != 200:
|
||||
msg = str(result["data"].get("message") or result["data"])
|
||||
_settle_after_ambiguous(db, order, reason=msg)
|
||||
db.refresh(order)
|
||||
if order.status == "failed":
|
||||
raise WithdrawTransferError(msg)
|
||||
return order
|
||||
|
||||
data = result["data"]
|
||||
@@ -413,6 +449,40 @@ def create_withdraw(
|
||||
return order
|
||||
|
||||
|
||||
def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder:
|
||||
"""按 out_bill_no 取提现单(admin 跨用户,不限 user_id)。不存在抛 WithdrawOrderNotFound。"""
|
||||
order = db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
|
||||
).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise WithdrawOrderNotFound
|
||||
return order
|
||||
|
||||
|
||||
def approve_withdraw(db: Session, out_bill_no: str) -> WithdrawOrder:
|
||||
"""管理员审核通过:校验 reviewing → 发起微信转账。返回最终 order(pending/success/failed)。
|
||||
|
||||
幂等防误操作:非 reviewing(已通过/已拒绝/已打款)抛 WithdrawNotReviewable,不会重复打款。
|
||||
"""
|
||||
order = _get_withdraw_or_raise(db, out_bill_no)
|
||||
if order.status != "reviewing":
|
||||
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能通过")
|
||||
return execute_withdraw_transfer(db, order)
|
||||
|
||||
|
||||
def reject_withdraw(db: Session, out_bill_no: str, reason: str) -> WithdrawOrder:
|
||||
"""管理员审核拒绝:校验 reviewing → 退回现金 + 置 rejected。
|
||||
|
||||
非 reviewing 抛 WithdrawNotReviewable(防对已打款单误退导致重复退款)。
|
||||
"""
|
||||
order = _get_withdraw_or_raise(db, out_bill_no)
|
||||
if order.status != "reviewing":
|
||||
raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能拒绝")
|
||||
_refund_withdraw(db, order, reason=reason or "审核未通过", final_status="rejected")
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def refresh_withdraw_status(
|
||||
db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
|
||||
) -> WithdrawOrder:
|
||||
|
||||
@@ -37,6 +37,10 @@ class AdRewardStatusOut(BaseModel):
|
||||
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
|
||||
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
|
||||
)
|
||||
# ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 =====
|
||||
watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)")
|
||||
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)")
|
||||
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)")
|
||||
|
||||
|
||||
class EcpmReportIn(BaseModel):
|
||||
@@ -57,6 +61,23 @@ class EcpmReportOut(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class WatchReportIn(BaseModel):
|
||||
"""客户端上报一次激励视频的实际观看时长(onAdClose 时,onAdShow→onAdClose 墙钟秒数)。
|
||||
|
||||
user_id 由 JWT 取(Bearer),不在 body。seconds 服务端会夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
"""
|
||||
|
||||
seconds: int = Field(..., ge=0, description="本次观看秒数")
|
||||
|
||||
|
||||
class WatchReportOut(BaseModel):
|
||||
"""上报后返回当日累计 + 剩余,客户端即时更新"今日还能看多久"。"""
|
||||
|
||||
watched_seconds_today: int = Field(..., description="今日累计观看秒数")
|
||||
watch_seconds_limit: int = Field(..., description="每日上限(秒)")
|
||||
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ class WithdrawRequest(BaseModel):
|
||||
|
||||
class WithdrawResultOut(BaseModel):
|
||||
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = Field(None, description="微信侧原始状态")
|
||||
amount_cents: int
|
||||
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
|
||||
@@ -118,9 +118,14 @@ class WithdrawResultOut(BaseModel):
|
||||
|
||||
class WithdrawStatusOut(BaseModel):
|
||||
out_bill_no: str
|
||||
status: str = Field(..., description="pending / success / failed")
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = None
|
||||
amount_cents: int
|
||||
fail_reason: str | None = Field(None, description="failed/rejected 时的原因(用户可读)")
|
||||
# 审核通过进入打款、且微信要求用户确认(WAIT_USER_CONFIRM)时,带回这三项供 App 拉起微信确认页
|
||||
package_info: str | None = None
|
||||
mch_id: str | None = None
|
||||
app_id: str | None = None
|
||||
|
||||
|
||||
class WithdrawOrderOut(BaseModel):
|
||||
@@ -129,7 +134,7 @@ class WithdrawOrderOut(BaseModel):
|
||||
id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = None
|
||||
fail_reason: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
+44
-6
@@ -120,27 +120,65 @@ def test_callback_bad_sign(client) -> None:
|
||||
assert _coin_balance(client, token) == 0
|
||||
|
||||
|
||||
def test_daily_cap(client) -> None:
|
||||
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
|
||||
def test_daily_count_cap(client, monkeypatch) -> None:
|
||||
"""达到每日发奖**次数兜底上限**后继续回调 → 受理但不发(capped),余额封顶。
|
||||
|
||||
次数上限现为时长闸的兜底(默认 200),这里 monkeypatch 调小到 3 加速(不真跑 200 次);
|
||||
本用例不上报观看时长 → 时长主闸不触发,纯验次数兜底。
|
||||
"""
|
||||
# 次数兜底现走 app_config getter(rebase 合 main 的运营可配后);patch getter 调小到 3
|
||||
monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 3)
|
||||
phone = "13800003004"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
for i in range(DAILY_AD_REWARD_LIMIT):
|
||||
for i in range(3):
|
||||
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
||||
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
|
||||
r = _callback(client, _signed(uid, "trans_cap_over"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
||||
assert _coin_balance(client, token) == 3 * AD_REWARD_COIN
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
|
||||
assert st["used_today"] == 3
|
||||
assert st["remaining"] == 0
|
||||
|
||||
|
||||
def test_daily_watch_time_cap(client, monkeypatch) -> None:
|
||||
"""看广告累计观看时长达每日上限(主闸,这里调小到 100s)后,再回调发奖 → capped 不发金币。
|
||||
|
||||
时长由前端 watch-report 上报累计;到顶后 ① watch-report/reward-status remaining=0(客户端不再展示),
|
||||
② 后端发奖也因时长闸记 capped(双闸,前端绕不过)。次数兜底不动它(此时只看了 1 次,远没到次数上限)。
|
||||
"""
|
||||
# 时长上限两处引用都要 patch:api 用 rewards.X、grant 闸/today_status 用 ad_reward 内绑定的 X
|
||||
monkeypatch.setattr("app.core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
monkeypatch.setattr("app.repositories.ad_reward.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
|
||||
phone = "13800003201"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 上报一次 100s 观看 → 当日累计达上限,remaining=0
|
||||
r = client.post("/api/v1/ad/watch-report", json={"seconds": 100}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["watched_seconds_today"] == 100
|
||||
assert r.json()["watch_seconds_remaining"] == 0
|
||||
|
||||
# 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币
|
||||
before = _coin_balance(client, token)
|
||||
r = _callback(client, _signed(uid, "trans_time_cap"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
assert _coin_balance(client, token) == before # 未加币
|
||||
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["watched_seconds_today"] == 100
|
||||
assert st["watch_seconds_limit"] == 100
|
||||
assert st["watch_seconds_remaining"] == 0
|
||||
|
||||
|
||||
def test_callback_unknown_user(client) -> None:
|
||||
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
||||
params = _signed(999999, "trans_ghost")
|
||||
|
||||
@@ -108,7 +108,8 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
|
||||
uid = _seed_user("13912340002")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_used, limit, _coin, _rc, _cd = ad_reward.today_status(db, uid)
|
||||
# today_status 现返 7 元组(末两位为观看时长闸:已看秒数 / 上限),取次数上限位
|
||||
_used, limit, _coin, _rc, _cd, _ws, _wl = ad_reward.today_status(db, uid)
|
||||
assert limit == 5 # 新配置生效
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+79
-10
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
@@ -43,6 +44,24 @@ def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵
|
||||
)
|
||||
|
||||
|
||||
def _approve(bill: str) -> None:
|
||||
"""模拟管理员审核通过(走 repo,等价 admin 端点 approve → execute_withdraw_transfer 发起转账)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.approve_withdraw(db, bill)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _reject(bill: str, reason: str = "不符合提现规则") -> None:
|
||||
"""模拟管理员审核拒绝(走 repo,等价 admin 端点 reject → 退款 + rejected)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.reject_withdraw(db, bill, reason)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bind_wechat(client, monkeypatch) -> None:
|
||||
_patch_userinfo(monkeypatch)
|
||||
token = _login(client, "13800002001")
|
||||
@@ -64,7 +83,8 @@ def test_bind_wechat(client, monkeypatch) -> None:
|
||||
assert j["wechat_nickname"] == "测试昵称"
|
||||
|
||||
|
||||
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
|
||||
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
@@ -77,13 +97,12 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
_seed_cash(client, token, "13800002002", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 50 分
|
||||
# 发起提现 50 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["package_info"] == "pkg123"
|
||||
assert body["mch_id"] and body["app_id"]
|
||||
assert body["status"] == "reviewing"
|
||||
assert body["package_info"] is None
|
||||
assert body["cash_balance_cents"] == 50 # 已扣
|
||||
bill = body["out_bill_no"]
|
||||
|
||||
@@ -92,7 +111,10 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
txns = r.json()["items"]
|
||||
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
|
||||
|
||||
# 查单 → 微信返回 SUCCESS → 归一化 success
|
||||
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
|
||||
_approve(bill)
|
||||
|
||||
# 用户确认到账后查单 → SUCCESS → 归一化 success
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
|
||||
@@ -123,8 +145,12 @@ def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) ->
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
bill = r.json()["out_bill_no"]
|
||||
assert r.json()["status"] == "reviewing"
|
||||
assert r.json()["cash_balance_cents"] == 50 # 已扣
|
||||
|
||||
# 管理员审核通过 → 发起转账(mock WAIT_USER_CONFIRM,单进 pending)
|
||||
_approve(bill)
|
||||
|
||||
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
@@ -166,7 +192,8 @@ def test_withdraw_not_bound(client) -> None:
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
def test_withdraw_approve_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
@@ -184,8 +211,14 @@ def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
_seed_cash(client, token, "13800002005", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 → reviewing(已扣款),此刻不打款不会失败
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 502, r.text
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "reviewing"
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
|
||||
_approve(bill)
|
||||
|
||||
# 余额已退回
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
@@ -222,7 +255,9 @@ def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
r2 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
assert calls["n"] == 1 # 只真发起一次转账
|
||||
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
|
||||
assert calls["n"] == 0
|
||||
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
|
||||
assert r1.json()["out_bill_no"] == bill
|
||||
assert r2.json()["out_bill_no"] == bill
|
||||
# 余额只扣一次(100-50=50)
|
||||
@@ -249,7 +284,13 @@ def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch)
|
||||
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "success"
|
||||
assert r.json()["status"] == "reviewing"
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 审核通过 → 转账调用超时(异常) → 查单确认 SUCCESS → 不退款,单 success
|
||||
_approve(bill)
|
||||
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
||||
assert r.json()["items"][0]["status"] == "success"
|
||||
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
@@ -269,3 +310,31 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
token_b = _login(client, "13800002010")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_withdraw_reject_refunds(client, monkeypatch) -> None:
|
||||
"""管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
token = _login(client, "13800002011")
|
||||
_seed_cash(client, token, "13800002011", 100)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
# 发起提现 → reviewing(已扣款,余额 50)
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.json()["status"] == "reviewing"
|
||||
assert r.json()["cash_balance_cents"] == 50
|
||||
bill = r.json()["out_bill_no"]
|
||||
|
||||
# 管理员拒绝 → 退款 + rejected(不调微信,无需 mock 转账)
|
||||
_reject(bill, "测试拒绝")
|
||||
|
||||
# 余额退回 100
|
||||
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
assert r.json()["cash_balance_cents"] == 100
|
||||
# 有退款流水
|
||||
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
||||
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
|
||||
# 单 rejected + 理由透出
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
assert r.json()["status"] == "rejected"
|
||||
assert r.json()["fail_reason"] == "测试拒绝"
|
||||
|
||||
Reference in New Issue
Block a user