feat(wallet): 微信商家转账「用户授权免确认收款」(免确认到账) (#16)
提现从「每次跳微信确认」升级为免确认模式:首次授权一次,之后审核通过直接到账,用户零跳转。 方式一(首单转账顺带授权 pre-transfer-with-authorization)+ 方式二(显式开启 user-confirm-authorization)。 - 新增 wechat_transfer_authorization 表(user 1:1,out_authorization_no/authorization_id/state) + 迁移 - wxpay.py 新增 apply/query/close_transfer_authorization + pre_transfer_with_authorization + transfer_with_authorization - repositories/wallet.py 授权 CRUD + sync/_refresh_active_auth + execute_withdraw_transfer 三分叉 (active->免确认转账 / 无授权且配了回调->方式一 / 未配->退化原确认模式);失效回查授权权威判定,绝不盲退 - /wallet/transfer-auth(开启)/status/close 路由 + withdraw-info 增 transfer_auth_enabled - 授权结果回调 stub /api/v1/wxpay/transfer-auth-notify(一期仅应答不验签,真值靠 query 轮询) - config 增 WXPAY_AUTH_NOTIFY_URL 一期无回调验签;二期补 V3 平台证书验签 + AEAD 解密。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
+65
-2
@@ -33,6 +33,8 @@ from app.schemas.welfare import (
|
||||
ExchangeInfoOut,
|
||||
ExchangeRequest,
|
||||
ExchangeResultOut,
|
||||
TransferAuthResultOut,
|
||||
TransferAuthStatusOut,
|
||||
UnbindWechatResultOut,
|
||||
WithdrawInfoOut,
|
||||
WithdrawOrderOut,
|
||||
@@ -155,15 +157,18 @@ 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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -191,9 +196,19 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
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)
|
||||
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
logger.info(
|
||||
"withdraw submitted user_id=%d cents=%d bill=%s status=%s(待审核)",
|
||||
"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 提示"已提交,等待审核"。
|
||||
@@ -246,3 +261,51 @@ 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)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""微信支付相关回调(一期:免确认收款授权结果通知 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"}
|
||||
Reference in New Issue
Block a user