Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29225e9b65 | |||
| 50ed726816 |
@@ -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')
|
||||
+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"}
|
||||
@@ -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。
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -107,6 +107,43 @@ 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):
|
||||
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
||||
|
||||
|
||||
+218
-21
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user