From 07c0b7502c3782c88d1491c5ca7eaec2b8c9fd50 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 6 Jun 2026 04:51:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(welfare):=20=E6=8F=90=E7=8E=B0=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E5=AE=A1=E6=A0=B8=E6=B5=81=E7=A8=8B=20+=20=E7=9C=8B?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E6=AF=8F=E6=97=A5=E6=97=B6=E9=95=BF=E9=99=90?= =?UTF-8?q?=E6=B5=81(=E9=98=B2=E5=88=B7=E4=B8=BB=E9=97=B8)=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提现人工审核(提现不再即时打款): - 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) --------- Co-authored-by: pure Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/15 --- .../versions/withdraw_review_and_ad_watch.py | 62 ++++++++++ app/admin/routers/withdraw.py | 57 ++++++++- app/admin/schemas/wallet.py | 9 +- app/api/v1/ad.py | 38 +++++- app/api/v1/wallet.py | 18 ++- app/core/rewards.py | 17 ++- app/models/__init__.py | 1 + app/models/ad_watch_log.py | 41 +++++++ app/models/wallet.py | 17 ++- app/repositories/ad_reward.py | 23 ++-- app/repositories/ad_watch.py | 36 ++++++ app/repositories/wallet.py | 114 ++++++++++++++---- app/schemas/ad.py | 21 ++++ app/schemas/welfare.py | 11 +- tests/test_ad_reward.py | 50 +++++++- tests/test_admin_config.py | 3 +- tests/test_withdraw.py | 89 ++++++++++++-- 17 files changed, 540 insertions(+), 67 deletions(-) create mode 100644 alembic/versions/withdraw_review_and_ad_watch.py create mode 100644 app/models/ad_watch_log.py create mode 100644 app/repositories/ad_watch.py diff --git a/alembic/versions/withdraw_review_and_ad_watch.py b/alembic/versions/withdraw_review_and_ad_watch.py new file mode 100644 index 0000000..8e5a0fb --- /dev/null +++ b/alembic/versions/withdraw_review_and_ad_watch.py @@ -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') diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index d080f1d..06940e3 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -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) diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index 7c64cd7..cd6de21 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -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,用户可见)" + ) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 80c1d94..ecff3b6 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -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"), diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 4b57894..3cd3c9c 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -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, ) diff --git a/app/core/rewards.py b/app/core/rewards.py index 56b770b..800a006 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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, # 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。 diff --git a/app/models/__init__.py b/app/models/__init__.py index 15486de..62bce18 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/ad_watch_log.py b/app/models/ad_watch_log.py new file mode 100644 index 0000000..5eb848c --- /dev/null +++ b/app/models/ad_watch_log.py @@ -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"" diff --git a/app/models/wallet.py b/app/models/wallet.py index 4ab4177..9698758 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -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) # 微信转账单号 diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index bf95d29..77bee4b 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -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, ) diff --git a/app/repositories/ad_watch.py b/app/repositories/ad_watch.py new file mode 100644 index 0000000..c98ed69 --- /dev/null +++ b/app/repositories/ad_watch.py @@ -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) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index bd5bf7c..09f7c0c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -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: diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 7ed42d0..a6cdb65 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -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): """[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。""" diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index c7630fc..9a95ba9 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -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 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index a7f2b65..c72bb39 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -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") diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 65427b2..1a5fead 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -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() diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 5eb8f8c..55e6a2f 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -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"] == "测试拒绝"