Compare commits

..

1 Commits

Author SHA1 Message Date
marco 88d2b57c7a feat(admin): app_config 运营配置后台(rewards 等常量可后台配置)
- 新增 app_config 表 + model/repository/config_schema + admin config 路由与 schema + 测试
- rewards.py 及 ad_reward/signin/task/wallet/comparison_milestone repositories 接入可配置项;ad.py / admin/main.py 配套
- alembic merge(ebb6af5c0b56)合并本地 app_config(cfg1a2b3c4d5)与 incoming price_report/best_deeplink(b7e2c1a9f4d3)两 head

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:29:49 +08:00
22 changed files with 78 additions and 1126 deletions
@@ -1,48 +0,0 @@
"""wechat_transfer_authorization 表(免确认收款授权)
商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。
一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。
Revision ID: wx_transfer_auth
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'wx_transfer_auth'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'wechat_transfer_authorization',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('openid', sa.String(length=64), nullable=False),
sa.Column('out_authorization_no', sa.String(length=64), nullable=False),
sa.Column('authorization_id', sa.String(length=64), nullable=True),
sa.Column('state', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
)
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'),
['out_authorization_no'],
unique=True,
)
def downgrade() -> None:
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'))
op.drop_table('wechat_transfer_authorization')
@@ -1,62 +0,0 @@
"""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')
+1 -56
View File
@@ -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, WithdrawRejectRequest
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut
from app.integrations import wxpay
from app.models.admin import AdminUser
from app.repositories import wallet as wallet_repo
@@ -83,58 +83,3 @@ 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)
+1 -8
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
class CoinTxnOut(BaseModel):
@@ -39,7 +39,6 @@ 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
@@ -51,9 +50,3 @@ 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,用户可见)"
)
+2 -36
View File
@@ -21,15 +21,12 @@ 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")
@@ -94,8 +91,7 @@ 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,
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
return AdRewardStatusOut(
used_today=used,
daily_limit=limit,
@@ -103,35 +99,6 @@ 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),
)
@@ -184,8 +151,7 @@ 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,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
used, limit, coin_per, round_count, cooldown_until = 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"),
+6 -77
View File
@@ -33,8 +33,6 @@ from app.schemas.welfare import (
ExchangeInfoOut,
ExchangeRequest,
ExchangeResultOut,
TransferAuthResultOut,
TransferAuthStatusOut,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
@@ -157,29 +155,25 @@ def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
u = db.get(User, user.id)
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
auth = crud_wallet.sync_transfer_auth(db, user.id)
return WithdrawInfoOut(
min_cents=WITHDRAW_MIN_CENTS,
max_cents=WITHDRAW_MAX_CENTS,
wechat_bound=bool(u and u.wechat_openid),
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
transfer_auth_enabled=bool(auth and auth.state == "active"),
)
@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:
@@ -195,24 +189,12 @@ 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
# 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。
# 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核,
# 生产绝不会被客户端 flag 绕过审核。用于本地联调"提现→微信转账/免确认到账"全链路。
if req.skip_review and not settings.is_prod and order.status == "reviewing":
logger.warning(
"withdraw skip_review(非prod调试直发,跳过人工审核立即打款) user_id=%d bill=%s",
user.id, order.out_bill_no,
)
order = crud_wallet.execute_withdraw_transfer(db, order)
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 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(若需确认)再拉起微信确认页。
logger.info("withdraw user_id=%d cents=%d bill=%s state=%s", user.id, req.amount_cents, order.out_bill_no, order.wechat_state)
return WithdrawResultOut(
out_bill_no=order.out_bill_no,
status=order.status,
@@ -241,11 +223,6 @@ 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,
)
@@ -261,51 +238,3 @@ def withdraw_orders(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
# ===== 免确认收款授权(用户授权免确认模式)=====
# 开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。
@router.post(
"/transfer-auth",
response_model=TransferAuthResultOut,
summary="开启免确认到账(申请授权,返回拉起微信授权页的 package)",
dependencies=[Depends(rate_limit(10, 60, "transfer-auth"))],
)
def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOut:
if not settings.wxpay_auth_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="wechat transfer auth not configured",
)
try:
info = crud_wallet.apply_transfer_auth(db, user.id)
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
except crud_wallet.WithdrawTransferError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
logger.info("open transfer-auth user_id=%d already_active=%s", user.id, info["already_active"])
return TransferAuthResultOut(**info)
@router.get(
"/transfer-auth/status",
response_model=TransferAuthStatusOut,
summary="查免确认授权状态(从微信授权页返回后轮询)",
)
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
auth = crud_wallet.sync_transfer_auth(db, user.id)
state = auth.state if auth else "none"
return TransferAuthStatusOut(state=state, enabled=(state == "active"))
@router.post(
"/transfer-auth/close",
response_model=TransferAuthStatusOut,
summary="关闭免确认到账(解除授权)",
)
def close_transfer_auth_endpoint(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
crud_wallet.close_transfer_auth(db, user.id)
logger.info("close transfer-auth user_id=%d", user.id)
return TransferAuthStatusOut(state="closed", enabled=False)
-30
View File
@@ -1,30 +0,0 @@
"""微信支付相关回调(一期:免确认收款授权结果通知 stub)。
⚠️ 一期不处理回调内容、不验签:授权状态以主动查询(query_transfer_authorization)为准。
本端点仅向微信回 200 避免重试风暴,**绝不依据回调内容改账**。二期接入时必须先补
V3 平台证书/公钥验签(Wechatpay-Signature) + APIv3 密钥 AEAD 解密,验签通过后方可信任并处理。
授权回调地址通过 settings.WXPAY_AUTH_NOTIFY_URL 配置,需指向本端点的公网地址。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
logger = logging.getLogger("shagua.wxpay")
router = APIRouter(prefix="/api/v1/wxpay", tags=["wxpay"])
@router.post("/transfer-auth-notify", summary="免确认收款授权结果通知(一期 stub:仅应答,不处理)")
async def transfer_auth_notify(request: Request) -> dict:
# 一期:不验签、不解密、不改账。仅记录 + 应答成功;真实授权状态靠 /transfer-auth/status 查询兜底。
try:
body = await request.json()
logger.info(
"transfer-auth notify id=%s type=%s", body.get("id"), body.get("event_type")
)
except Exception: # noqa: BLE001 — body 解析失败也照常应答 200,避免微信重试
logger.info("transfer-auth notify (unparseable body)")
return {"code": "SUCCESS", "message": "OK"}
-9
View File
@@ -99,10 +99,6 @@ class Settings(BaseSettings):
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
WXPAY_AUTH_NOTIFY_URL: str = ""
@property
def wxpay_configured(self) -> bool:
@@ -114,11 +110,6 @@ class Settings(BaseSettings):
and self.WXPAY_PUBLIC_KEY_ID
)
@property
def wxpay_auth_configured(self) -> bool:
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
+3 -14
View File
@@ -82,20 +82,9 @@ def record_milestone_reward(milestone: int) -> int:
AD_REWARD_COIN: int = 666
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)
# 主闸是 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
# 每用户每日发奖次数上限,防刷 + 控成本
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 20
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
-165
View File
@@ -24,10 +24,6 @@ from app.core.config import settings
_API_HOST = "https://api.mch.weixin.qq.com"
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
# 免确认收款授权(用户授权免确认模式)
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
_PRE_TRANSFER_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization"
_TRANSFER_WITH_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/transfer"
# 懒加载缓存
_private_key: RSAPrivateKey | None = None
@@ -203,164 +199,3 @@ def code_to_userinfo(code: str) -> dict:
pass
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续转账走 transfer_with_authorization 免逐笔确认直接到账。
# 复用上面同一套 V3 签名(_build_authorization)、敏感字段加密(encrypt_sensitive)、懒加载密钥。
def _transfer_report_infos() -> list[dict]:
"""现金营销(scene 1000)转账场景报备信息,与 create_transfer 同口径。"""
return [
{"info_type": "活动名称", "info_content": "比价返现"},
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
]
def apply_transfer_authorization(
out_authorization_no: str,
openid: str,
user_display_name: str,
notify_url: str,
*,
scene_info: dict | None = None,
) -> dict:
"""发起免确认收款授权(方式二:不转账,仅申请授权)。返回 {status_code, data}。
成功(200 + state=WAIT_USER_CONFIRM)时 data 带 package_info,供 App 拉起微信授权页。"""
body: dict = {
"out_authorization_no": out_authorization_no,
"appid": settings.WECHAT_APP_ID,
"openid": openid,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"user_display_name": user_display_name,
"authorization_notify_url": notify_url,
}
if scene_info:
body["scene_info"] = scene_info
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def query_transfer_authorization(out_authorization_no: str) -> dict:
"""按商户授权单号查授权结果。返回 {status_code, data}。
data.state: WAIT_USER_CONFIRM / TAKING_EFFECT(已生效) / CLOSED;TAKING_EFFECT 时带 authorization_id。"""
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}"
headers = {
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def close_transfer_authorization(out_authorization_no: str) -> dict:
"""解除免确认收款授权。返回 {status_code, data}(成功 data.state=CLOSED)。"""
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}/close"
headers = {
"Authorization": _build_authorization("POST", path, ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
return {"status_code": resp.status_code, "data": resp.json()}
def pre_transfer_with_authorization(
openid: str,
amount_fen: int,
out_bill_no: str,
out_authorization_no: str,
user_display_name: str,
notify_url: str,
user_name: str | None = None,
) -> dict:
"""方式一:发起转账并同时申请免确认收款授权(用户在确认这笔收款时一并完成授权)。
返回 {status_code, data};成功 state=WAIT_USER_CONFIRM 时带 package_info(拉确认+授权页)。"""
body: dict = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"openid": openid,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": _transfer_report_infos(),
"authorization_info": {
"user_display_name": user_display_name,
"out_authorization_no": out_authorization_no,
"authorization_notify_url": notify_url,
},
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _PRE_TRANSFER_AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
def transfer_with_authorization(
authorization_id: str,
amount_fen: int,
out_bill_no: str,
user_name: str | None = None,
) -> dict:
"""方式二 / 二次起:用户已授权后免确认转账(无需用户逐笔确认,直接到账)。返回 {status_code, data}。
成功 state ∈ ACCEPTED/PROCESSING/TRANSFERING/SUCCESS(无 WAIT_USER_CONFIRM、无 package_info)。"""
body: dict = {
"appid": settings.WECHAT_APP_ID,
"out_bill_no": out_bill_no,
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
"transfer_amount": amount_fen,
"transfer_remark": "金币提现",
"transfer_scene_report_infos": _transfer_report_infos(),
"authorization_id": authorization_id,
}
if user_name and amount_fen >= 30:
body["user_name"] = encrypt_sensitive(user_name)
body_str = json.dumps(body, ensure_ascii=False)
headers = {
"Authorization": _build_authorization("POST", _TRANSFER_WITH_AUTH_PATH, body_str),
"Accept": "application/json",
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
content=body_str,
headers=headers,
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
return {"status_code": resp.status_code, "data": resp.json()}
-2
View File
@@ -29,7 +29,6 @@ from app.api.v1.signin import router as signin_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.logging import setup_logging
@@ -83,7 +82,6 @@ app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
app.include_router(meituan_router)
app.include_router(wallet_router)
app.include_router(wxpay_router)
app.include_router(signin_router)
app.include_router(tasks_router)
app.include_router(savings_router)
-1
View File
@@ -1,7 +1,6 @@
"""所有 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
-41
View File
@@ -1,41 +0,0 @@
"""看激励视频观看时长记录(每日总时长防刷)。
每条 = 客户端 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}>"
+6 -48
View File
@@ -65,14 +65,11 @@ class CoinTransaction(Base):
class WithdrawOrder(Base):
"""提现单(现金 → 微信零钱)。状态机:
reviewing →(管理员通过)→ pending → success / failed
reviewing →(管理员拒绝)→ rejected(已退款)
"""提现单(现金 → 微信零钱)。状态机:pending → success / failed。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务,初始 reviewing(待人工审核,
**不打款**);管理员审核通过才调微信转账(execute_withdraw_transfer)→ pending,拒绝则退回
现金 + 置 rejected。微信侧失败/取消时退回现金并写 cash_transaction(withdraw_refund) → failed
wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED ...)。
扣现金 + 写 cash_transaction(withdraw) 与建本单在同一事务;失败/取消时退回现金
并写 cash_transaction(withdraw_refund)。wechat_state 存微信侧原始状态(WAIT_USER_CONFIRM /
SUCCESS / FAIL / CANCELLED ...),status 是我们归一化后的三态
"""
__tablename__ = "withdraw_order"
@@ -84,10 +81,8 @@ 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)
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
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")
# 归一化状态:pending / success / failed
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# 微信侧原始状态
wechat_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 微信转账单号
@@ -107,43 +102,6 @@ class WithdrawOrder(Base):
return f"<WithdrawOrder id={self.id} user_id={self.user_id} cents={self.amount_cents} {self.status}>"
class WechatTransferAuthorization(Base):
"""微信商家转账「免确认收款授权」(用户授权免确认模式)。一个用户一条(user_id 主键)。
状态机:
(无) →(申请 / 首单转账顺带申请)→ pending(微信 WAIT_USER_CONFIRM,待用户在微信确认授权)
pending →(用户确认授权)→ active(微信 TAKING_EFFECT,此后转账免用户逐笔确认)
pending / active →(用户在微信关 / 商户解除 / 风控)→ closed(终态,需重新开启)
out_authorization_no 我方生成(查授权 / 发起授权用,一个用户一条稳定值,重开时换新);
authorization_id 微信在 active 后返回,免确认转账(transfer_with_authorization)时必传。
"""
__tablename__ = "wechat_transfer_authorization"
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), primary_key=True
)
openid: Mapped[str] = mapped_column(String(64), nullable=False)
# 商户侧授权单号(我方生成),唯一
out_authorization_no: Mapped[str] = mapped_column(
String(64), unique=True, index=True, nullable=False
)
# 微信侧授权单号(active 后返回,免确认转账要用)
authorization_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# pending(待用户确认) / active(已生效可免确认) / closed(已关闭需重开)
state: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<WechatTransferAuthorization user_id={self.user_id} state={self.state}>"
class CashTransaction(Base):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
+7 -16
View File
@@ -18,11 +18,10 @@ from sqlalchemy.orm import Session
from app.core import rewards
from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
from app.core.rewards import 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):
@@ -72,12 +71,8 @@ def grant_ad_reward(
today = cn_today().isoformat()
# #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:
# #3 每日上限:超了记一条 capped(不发金币),让审计能看到"今天到顶了"
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw,
@@ -131,14 +126,12 @@ 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, int, int]:
) -> tuple[int, int, int, int, datetime | None]:
"""客户端查"今日看广告发奖"进度。
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
今日已观看总秒数, 每日观看总时长上限(秒))。
次数上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);次数维度的"本轮已看
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟
主闸)查 ad_watch_log 当日累计——前端据此展示"今日已看 X/50 分钟"+ 满则不给看。
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
上限/单次金币/每轮次数/冷却秒 均从 app_config 读(运营后台可改);冷却策略判断委托
[app.core.ad_cooldown.compute_cooldown](纯函数)。
"""
today = cn_today().isoformat()
granted_desc = _granted_times_today_desc(db, user_id, today)
@@ -154,6 +147,4 @@ 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,
)
-36
View File
@@ -1,36 +0,0 @@
"""看激励视频观看时长 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)
+32 -299
View File
@@ -7,7 +7,6 @@
from __future__ import annotations
import re
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
@@ -15,25 +14,15 @@ from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.config import settings
from app.core.rewards import COIN_PER_CENT, coins_to_cents
from app.integrations import wxpay
from app.models.user import User
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
WechatTransferAuthorization,
WithdrawOrder,
)
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
# 免确认收款授权状态
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
class InvalidExchangeAmountError(Exception):
@@ -68,10 +57,6 @@ 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)
@@ -269,18 +254,10 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
return bal
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 # 防重复退款(并发/对账与查单/重复拒绝同时触发)
def _refund_withdraw(db: Session, order: WithdrawOrder, reason: str) -> None:
"""转账失败/取消:退回现金 + 写 withdraw_refund 流水 + 单子置 failed。幂等:已 failed 不重复退。"""
if order.status == "failed":
return # 防重复退款(并发/对账与查单同时触发)
bal = _add_cash(db, order.user_id, order.amount_cents)
db.add(
CashTransaction(
@@ -289,11 +266,10 @@ def _refund_withdraw(
balance_after_cents=bal,
biz_type="withdraw_refund",
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
remark="提现未成功,金额已退回", # 用户可见;技术原因记在 order.fail_reason
)
)
order.status = final_status
order.status = "failed"
order.fail_reason = reason[:256]
db.commit()
@@ -351,26 +327,22 @@ def create_withdraw(
user_name: str | None = None,
out_bill_no: str | None = None,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**
"""发起提现(原子扣款 + 幂等 + 模糊失败查单)
打款推迟到管理员在 admin 后台审核通过(approve_withdraw execute_withdraw_transfer)才发起;
拒绝则退回现金(reject_withdraw)
#1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
重复发起多笔提现(审核拒绝再退回)
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)
#1 用原子条件 UPDATE 扣款,杜绝并发超额。
#2 out_bill_no 由客户端提供作幂等键:同号重试不会重复转账,直接返回该单当前状态。
#3 微信调用结果不明时先查单再决定,绝不盲目退款。
返回提现(可能含 package_info App 拉起确认页)
"""
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 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
# 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
# #2 幂等:客户端传了合法 out_bill_no 且该单已存在 → 返回现状(pending 的顺手查一下),不重复发起
if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
existing = db.execute(
select(WithdrawOrder).where(
@@ -378,6 +350,8 @@ 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
@@ -400,282 +374,41 @@ 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,
user_name=user_name,
status="reviewing",
user_id=user_id, out_bill_no=out_bill_no, amount_cents=amount_cents, status="pending"
)
db.add(order)
db.commit()
db.refresh(order)
return order # 待管理员审核;**不在此处打款**
# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。
# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。
def _auth_display_name(user: User | None) -> str:
"""微信授权页展示的"开通账号"昵称(≤32,utf8)。
微信对 user_display_name 校验极严:**连空格和标点(. )都算"控制字符"拒收**实测
'wonderable ai'(带空格) 400 'wonderableai'/'周周'/'傻瓜比价用户8888' 才过
故只保留 文字(Unicode L*,中英文/CJK)与数字(N*),其余(emoji/符号/空格/标点/控制符/
零宽连接符/变体选择符/星形面字符)一律剔除;清空则兜底手机号尾号
"""
raw = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
kept = [
ch for ch in raw
if ord(ch) <= 0xFFFF and unicodedata.category(ch)[0] in ("L", "N")
]
name = "".join(kept).strip()
if not name and user and user.phone:
tail = user.phone[-4:] if len(user.phone) >= 4 else user.phone
name = f"傻瓜比价用户{tail}"
return (name or "傻瓜比价用户")[:32]
def _refresh_active_auth(db: Session, user_id: int) -> None:
"""免确认转账失败后回查授权有效性(权威判定,不靠猜错误码):
微信侧明确非生效(用户在微信关闭 / 风控 / 无此单) closed,下次提现自动回退方式一重新授权;
仍生效(失败实为商户余额不足等与授权无关的原因) 保持 active,下次免确认重试
仅对本地 active 记录查询;查询本身失败(5xx/网络)不改状态,下次再核,避免误关有效授权"""
auth = db.get(WechatTransferAuthorization, user_id)
if auth is None or auth.state != "active" or not settings.wxpay_configured:
return
# 2) 调微信转账;结果不明先查单再决定(#3)
try:
res = wxpay.query_transfer_authorization(auth.out_authorization_no)
except Exception: # noqa: BLE001 — 查询失败保持 active,下次再核
return
if res["status_code"] == 200:
if res["data"].get("state") != _WX_AUTH_ACTIVE: # CLOSED / 其他非生效 → 失效
auth.state = "closed"
db.commit()
elif _wx_not_found(res): # 明确无此授权单 → 失效
auth.state = "closed"
db.commit()
# 其他查询错误(5xx 等):不改状态,保持 active,下次再核
def sync_transfer_auth(db: Session, user_id: int) -> WechatTransferAuthorization | None:
"""同步待确认授权最新状态(仅 pending 时查微信):
TAKING_EFFECT active + authorization_id;CLOSED/超期无此单 closed返回最新记录( None)
pending(active/closed)直接返回不查;查询异常或缺凭证则保持原状"""
auth = db.get(WechatTransferAuthorization, user_id)
if auth is None or auth.state != "pending" or not settings.wxpay_configured:
return auth
try:
res = wxpay.query_transfer_authorization(auth.out_authorization_no)
except Exception: # noqa: BLE001 — 查询失败保持 pending,下次再同步
return auth
if res["status_code"] != 200:
if _wx_not_found(res): # 超期未确认被关闭 → closed;其他错误保持 pending
auth.state = "closed"
db.commit()
return auth
state = res["data"].get("state", "")
if state == _WX_AUTH_ACTIVE:
auth.state = "active"
auth.authorization_id = res["data"].get("authorization_id") or auth.authorization_id
db.commit()
elif state == _WX_AUTH_CLOSED:
auth.state = "closed"
db.commit()
# WAIT_USER_CONFIRM:保持 pending,等用户确认
return auth
def _ensure_pending_auth_no(db: Session, user_id: int, openid: str) -> str:
""""方式一(转账顺带授权)"准备待确认授权单号:
已有 pending 复用其 out_authorization_no(避免重复申请刷满"同场景≤5"上限);
/ closed 生成新单号并 upsert pending返回 out_authorization_no"""
auth = db.get(WechatTransferAuthorization, user_id)
if auth is not None and auth.state == "pending":
if auth.openid != openid: # 换绑过 → 刷新快照
auth.openid = openid
db.commit()
return auth.out_authorization_no
new_no = uuid.uuid4().hex # 32 位,符合 [0-9A-Za-z_-]{8,32}
if auth is None:
db.add(
WechatTransferAuthorization(
user_id=user_id, openid=openid, out_authorization_no=new_no,
authorization_id=None, state="pending",
)
)
else: # closed → 重新开启,换新单号
auth.openid = openid
auth.out_authorization_no = new_no
auth.authorization_id = None
auth.state = "pending"
db.commit()
return new_no
def apply_transfer_auth(db: Session, user_id: int) -> dict:
"""方式二:显式开启免确认收款(申请授权,不转账)。
返回 {already_active, package_info, mch_id, app_id};already_active=True 表示已开启无需再授权
未绑微信抛 WechatNotBoundError;微信返回非 200 WithdrawTransferError"""
user = db.get(User, user_id)
openid = user.wechat_openid if user else None
if not openid:
raise WechatNotBoundError
auth = db.get(WechatTransferAuthorization, user_id)
if auth is not None and auth.state == "active" and auth.authorization_id:
return {
"already_active": True, "package_info": None,
"mch_id": settings.WXPAY_MCH_ID, "app_id": settings.WECHAT_APP_ID,
}
out_auth_no = _ensure_pending_auth_no(db, user_id, openid)
result = wxpay.apply_transfer_authorization(
out_auth_no, openid, _auth_display_name(user), settings.WXPAY_AUTH_NOTIFY_URL
)
if result["status_code"] != 200:
raise WithdrawTransferError(str(result["data"].get("message") or result["data"]))
return {
"already_active": False,
"package_info": result["data"].get("package_info"),
"mch_id": settings.WXPAY_MCH_ID,
"app_id": settings.WECHAT_APP_ID,
}
def close_transfer_auth(db: Session, user_id: int) -> None:
"""关闭免确认收款(解除授权)。best-effort 调微信解除 + 本地置 closed。"""
auth = db.get(WechatTransferAuthorization, user_id)
if auth is None:
return
if auth.state != "closed" and settings.wxpay_configured:
try:
wxpay.close_transfer_authorization(auth.out_authorization_no)
except Exception: # noqa: BLE001 — 微信解除失败不阻塞本地置 closed(用户也可在微信侧自行关闭)
pass
auth.state = "closed"
db.commit()
def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> WithdrawOrder:
"""落微信转账应答到提现单:记 state/转账单号/package_info,SUCCESS 即 success。"""
order.wechat_state = data.get("state")
order.transfer_bill_no = data.get("transfer_bill_no")
order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页
if data.get("state") == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
db.refresh(order)
return order
def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
"""审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。**不抛异常**。
免确认收款(用户授权免确认模式)分叉:
已有生效授权(active) transfer_with_authorization 免确认转账,直接到账, package_info
无生效授权:
- 已配 WXPAY_AUTH_NOTIFY_URL pre_transfer_with_authorization(方式一):转账 + 顺带申请授权,
返回 WAIT_USER_CONFIRM + package_info,用户在确认这笔收款时一并授权,下次起免确认
- 未配回调地址(未启用免确认) 退化为原 create_transfer 确认模式,行为同改造前
免确认转账失败 _settle_after_ambiguous 保金额安全, _refresh_active_auth 回查授权,失效则标 closed(下次回退方式一)
用户审核期间解绑微信 退款 failed结果不明(超时/非200) 先查单再决定,绝不盲目退款
"""
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
# 先同步待确认授权:捕获首单确认后微信已生效的 authorization_id(pending→active),或失效(→closed)
auth = sync_transfer_auth(db, order.user_id)
order.status = "pending" # 进入打款在途(转账调用前崩溃留 pending,交对账兜底)
db.commit()
# ① 有生效授权 → 免确认转账(无需用户确认,直接到账)
if auth is not None and auth.state == "active" and auth.authorization_id:
try:
result = wxpay.transfer_with_authorization(
auth.authorization_id, 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)
return order
if result["status_code"] != 200:
# 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
# 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
_refresh_active_auth(db, order.user_id)
db.refresh(order)
return order
return _apply_transfer_result(db, order, result["data"])
# ② 无生效授权 → 启用免确认则转账+顺带授权(方式一),否则退化为原确认模式
try:
if settings.WXPAY_AUTH_NOTIFY_URL:
out_auth_no = _ensure_pending_auth_no(db, order.user_id, openid)
result = wxpay.pre_transfer_with_authorization(
openid, order.amount_cents, order.out_bill_no,
out_authorization_no=out_auth_no,
user_display_name=_auth_display_name(user),
notify_url=settings.WXPAY_AUTH_NOTIFY_URL,
user_name=order.user_name,
)
else:
result = wxpay.create_transfer(
openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
)
result = wxpay.create_transfer(openid, amount_cents, out_bill_no, user_name=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:
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
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
return _apply_transfer_result(db, order, result["data"])
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")
data = result["data"]
order.wechat_state = data.get("state")
order.transfer_bill_no = data.get("transfer_bill_no")
order.package_info = data.get("package_info")
if data.get("state") == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
db.refresh(order)
return order
-21
View File
@@ -37,10 +37,6 @@ 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):
@@ -61,23 +57,6 @@ class EcpmReportOut(BaseModel):
ok: bool = True
class WatchReportIn(BaseModel):
"""客户端上报一次激励视频的实际观看时长(onAdClose 时,onAdShow→onAdClose 墙钟秒数)。
user_id JWT (Bearer),不在 bodyseconds 服务端会夹 [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):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
+3 -32
View File
@@ -80,23 +80,6 @@ class WithdrawInfoOut(BaseModel):
wechat_bound: bool = Field(..., description="当前用户是否已绑定微信")
wechat_nickname: str | None = Field(None, description="微信昵称(可能为空/脱敏)")
wechat_avatar_url: str | None = Field(None, description="微信头像 URL(可能为空)")
transfer_auth_enabled: bool = Field(
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
)
# ===== 免确认收款授权(用户授权免确认模式)=====
class TransferAuthResultOut(BaseModel):
already_active: bool = Field(False, description="是否已是开启状态(无需再授权)")
package_info: str | None = Field(None, description="拉起微信授权页的 package(已开启时为空)")
mch_id: str | None = None
app_id: str | None = None
class TransferAuthStatusOut(BaseModel):
state: str = Field(..., description="none(未开启)/pending(待确认)/active(已开启)/closed(已关闭)")
enabled: bool = Field(..., description="是否已开启免确认到账")
class BindWechatRequest(BaseModel):
@@ -119,18 +102,11 @@ class WithdrawRequest(BaseModel):
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
)
skip_review: bool = Field(
False,
description=(
"调试直发:跳过人工审核立即打款。仅非生产(APP_ENV!=prod)生效,"
"客户端仅 debug 包下发(开发设置开关)。生产恒走人工审核。"
),
)
class WithdrawResultOut(BaseModel):
out_bill_no: str = Field(..., description="商户提现单号(查单用)")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
status: str = Field(..., description="pending / success / failed")
wechat_state: str | None = Field(None, description="微信侧原始状态")
amount_cents: int
cash_balance_cents: int = Field(..., description="提现后现金余额(分)")
@@ -142,14 +118,9 @@ class WithdrawResultOut(BaseModel):
class WithdrawStatusOut(BaseModel):
out_bill_no: str
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
status: str = Field(..., description="pending / success / failed")
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):
@@ -158,7 +129,7 @@ class WithdrawOrderOut(BaseModel):
id: int
out_bill_no: str
amount_cents: int
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
status: str
wechat_state: str | None = None
fail_reason: str | None = None
created_at: datetime
+6 -44
View File
@@ -120,65 +120,27 @@ def test_callback_bad_sign(client) -> None:
assert _coin_balance(client, token) == 0
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)
def test_daily_cap(client) -> None:
"""达到每日上限后继续回调 → 受理但不发(capped),余额封顶。"""
phone = "13800003004"
token = _login(client, phone)
uid = _user_id(phone)
for i in range(3):
for i in range(DAILY_AD_REWARD_LIMIT):
r = _callback(client, _signed(uid, f"trans_cap_{i}"))
assert r.status_code == 200, r.text
# 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
# 第 limit+1 次:capped,仍 is_valid不让穿山甲重试),但不加币
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) == 3 * AD_REWARD_COIN
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 3
assert st["used_today"] == DAILY_AD_REWARD_LIMIT
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")
+1 -2
View File
@@ -108,8 +108,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
uid = _seed_user("13912340002")
db = SessionLocal()
try:
# today_status 现返 7 元组(末两位为观看时长闸:已看秒数 / 上限),取次数上限位
_used, limit, _coin, _rc, _cd, _ws, _wl = ad_reward.today_status(db, uid)
_used, limit, _coin, _rc, _cd = ad_reward.today_status(db, uid)
assert limit == 5 # 新配置生效
finally:
db.close()
+10 -79
View File
@@ -10,7 +10,6 @@ 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:
@@ -44,24 +43,6 @@ 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")
@@ -83,8 +64,7 @@ def test_bind_wechat(client, monkeypatch) -> None:
assert j["wechat_nickname"] == "测试昵称"
def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
def test_withdraw_success_flow(client, monkeypatch) -> None:
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",
@@ -97,12 +77,13 @@ def test_withdraw_submit_then_approve_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 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
# 发起提现 50 分
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"] == "reviewing"
assert body["package_info"] is None
assert body["status"] == "pending"
assert body["package_info"] == "pkg123"
assert body["mch_id"] and body["app_id"]
assert body["cash_balance_cents"] == 50 # 已扣
bill = body["out_bill_no"]
@@ -111,10 +92,7 @@ def test_withdraw_submit_then_approve_success_flow(client, monkeypatch) -> None:
txns = r.json()["items"]
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 用户确认到账后查单 → SUCCESS → 归一化 success
# 查单 → 微信返回 SUCCESS → 归一化 success
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
@@ -145,12 +123,8 @@ 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",
@@ -192,8 +166,7 @@ def test_withdraw_not_bound(client) -> None:
assert r.status_code == 400, r.text
def test_withdraw_approve_transfer_fail_refunds(client, monkeypatch) -> None:
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
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",
@@ -211,14 +184,8 @@ def test_withdraw_approve_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 == 200, r.text
assert r.json()["status"] == "reviewing"
bill = r.json()["out_bill_no"]
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
_approve(bill)
assert r.status_code == 502, r.text
# 余额已退回
r = client.get("/api/v1/wallet/account", headers=_auth(token))
@@ -255,9 +222,7 @@ 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
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
assert calls["n"] == 0
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
assert calls["n"] == 1 # 只真发起一次转账
assert r1.json()["out_bill_no"] == bill
assert r2.json()["out_bill_no"] == bill
# 余额只扣一次(100-50=50)
@@ -284,13 +249,7 @@ 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"] == "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"
assert r.json()["status"] == "success"
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 50
@@ -310,31 +269,3 @@ 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"] == "测试拒绝"