From b59dc3ac1939d8d957c628796b1047a45c19bd61 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 6 Jun 2026 18:52:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20=E5=BE=AE=E4=BF=A1=E5=95=86?= =?UTF-8?q?=E5=AE=B6=E8=BD=AC=E8=B4=A6=E3=80=8C=E7=94=A8=E6=88=B7=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E5=85=8D=E7=A1=AE=E8=AE=A4=E6=94=B6=E6=AC=BE=E3=80=8D?= =?UTF-8?q?(=E5=85=8D=E7=A1=AE=E8=AE=A4=E5=88=B0=E8=B4=A6)=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提现从「每次跳微信确认」升级为免确认模式:首次授权一次,之后审核通过直接到账,用户零跳转。 方式一(首单转账顺带授权 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) Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/16 --- .../versions/wechat_transfer_authorization.py | 48 ++++ app/api/v1/wallet.py | 67 ++++- app/api/v1/wxpay.py | 30 +++ app/core/config.py | 9 + app/integrations/wxpay.py | 165 ++++++++++++ app/main.py | 2 + app/models/wallet.py | 37 +++ app/repositories/wallet.py | 239 ++++++++++++++++-- app/schemas/welfare.py | 24 ++ 9 files changed, 598 insertions(+), 23 deletions(-) create mode 100644 alembic/versions/wechat_transfer_authorization.py create mode 100644 app/api/v1/wxpay.py diff --git a/alembic/versions/wechat_transfer_authorization.py b/alembic/versions/wechat_transfer_authorization.py new file mode 100644 index 0000000..398a401 --- /dev/null +++ b/alembic/versions/wechat_transfer_authorization.py @@ -0,0 +1,48 @@ +"""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') diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 3cd3c9c..c0bc017 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -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) diff --git a/app/api/v1/wxpay.py b/app/api/v1/wxpay.py new file mode 100644 index 0000000..8844cae --- /dev/null +++ b/app/api/v1/wxpay.py @@ -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"} diff --git a/app/core/config.py b/app/core/config.py index 9f86f76..83d0ba6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -99,6 +99,10 @@ 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: @@ -110,6 +114,11 @@ 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。 diff --git a/app/integrations/wxpay.py b/app/integrations/wxpay.py index d52be17..06e0915 100644 --- a/app/integrations/wxpay.py +++ b/app/integrations/wxpay.py @@ -24,6 +24,10 @@ 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 @@ -199,3 +203,164 @@ 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()} diff --git a/app/main.py b/app/main.py index 43d83f1..d78d455 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ 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 @@ -82,6 +83,7 @@ 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) diff --git a/app/models/wallet.py b/app/models/wallet.py index 9698758..9aff2ea 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -107,6 +107,43 @@ class WithdrawOrder(Base): return f"" +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"" + + class CashTransaction(Base): """现金流水(单位:分)。金币兑现金、提现都记在这里。""" diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 09f7c0c..a0db153 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -7,6 +7,7 @@ from __future__ import annotations import re +import unicodedata import uuid from datetime import datetime, timedelta, timezone @@ -14,15 +15,25 @@ 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, WithdrawOrder +from app.models.wallet import ( + CashTransaction, + CoinAccount, + CoinTransaction, + WechatTransferAuthorization, + 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): @@ -405,13 +416,175 @@ def create_withdraw( return order # 待管理员审核;**不在此处打款** -def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder: - """审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。 +# ===== 免确认收款授权(用户授权免确认模式)===== +# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。 +# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。 - 流程:reviewing → 置 pending → 调微信转账 → SUCCESS=success / 失败/取消=退款 failed / - 结果不明=先查单再决定。**不抛异常**(admin 调用方按 order.status 判断结果)。 - 用户在审核期间解绑微信 → 退款 failed(打不了款)。 - 可能返回 package_info(WAIT_USER_CONFIRM 场景:需用户在 App 端确认页确认后才真正到账)。 + +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 + 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 @@ -420,33 +593,57 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde 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: - result = wxpay.create_transfer( - openid, order.amount_cents, order.out_bill_no, user_name=order.user_name - ) + 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 + ) except Exception as e: # noqa: BLE001 — 超时/网络异常≠失败,查单确认 _settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}") db.refresh(order) return order if result["status_code"] != 200: - msg = str(result["data"].get("message") or result["data"]) - _settle_after_ambiguous(db, order, reason=msg) + _settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"])) db.refresh(order) return order - 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 + return _apply_transfer_result(db, order, result["data"]) def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder: diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 102f27e..a48c65f 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -80,6 +80,23 @@ 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): @@ -102,6 +119,13 @@ class WithdrawRequest(BaseModel): out_bill_no: str | None = Field( None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成" ) + skip_review: bool = Field( + False, + description=( + "调试直发:跳过人工审核立即打款。仅非生产(APP_ENV!=prod)生效," + "客户端仅 debug 包下发(开发设置开关)。生产恒走人工审核。" + ), + ) class WithdrawResultOut(BaseModel):