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:
2026-06-06 18:52:42 +08:00
parent e252277431
commit b59dc3ac19
9 changed files with 598 additions and 23 deletions
+65 -2
View File
@@ -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)
+30
View File
@@ -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"}