From 23945c0b101fe33984807082828547e5ab086775 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Fri, 17 Jan 2025 16:51:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E7=9B=B8=E5=85=B3=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/wechat.py | 162 +++++++++++++++++++++++++++++++++++- app/core/config.py | 5 +- app/core/wechat.py | 93 ++++++++++++++++++++- app/models/order.py | 6 +- requirements.txt | 3 +- 5 files changed, 262 insertions(+), 7 deletions(-) diff --git a/app/api/endpoints/wechat.py b/app/api/endpoints/wechat.py index 628a7ce..d31f7d3 100644 --- a/app/api/endpoints/wechat.py +++ b/app/api/endpoints/wechat.py @@ -1,11 +1,19 @@ -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Response, Request from sqlalchemy.orm import Session from app.models.database import get_db from app.models.user import UserInfo,UserDB, PhoneLoginRequest, generate_user_code +from app.models.order import ShippingOrderDB, OrderStatus from app.core.response import success_response, error_response, ResponseModel -from app.core.wechat import WeChatClient +from app.core.wechat import WeChatClient, get_wechat_pay_client from app.core.security import create_access_token, set_jwt_cookie from pydantic import BaseModel +import json +import time +from datetime import datetime, timezone +from app.api.deps import get_current_user +from app.core.config import settings +import random +import string router = APIRouter() @@ -73,4 +81,152 @@ async def wechat_phone_login( ) except Exception as e: db.rollback() - return error_response(code=500, message=f"登录失败: {str(e)}") \ No newline at end of file + return error_response(code=500, message=f"登录失败: {str(e)}") + +@router.post("/pay/order", response_model=ResponseModel) +async def create_wechat_pay_order( + orderid: str, + db: Session = Depends(get_db), + current_user: UserDB = Depends(get_current_user) +): + """创建微信支付订单""" + # 查询订单 + order = db.query(ShippingOrderDB).filter( + ShippingOrderDB.orderid == orderid, + ShippingOrderDB.userid == current_user.userid, + ShippingOrderDB.status == OrderStatus.UNPAID # 只能支付新创建的订单 + ).first() + + if not order: + return error_response(code=404, message="订单不存在或状态不正确") + + # 检查是否已经支付 + if order.pay_status: + return error_response(code=400, message="订单已支付") + + try: + # 获取微信支付客户端 + wx_pay_client = WeChatClient() + + # 创建支付订单 + resp_data = wx_pay_client.create_jsapi_payment( + orderid=orderid, + amount=int(order.final_amount * 100), + openid=current_user.openid, + description=f"蜂快到家-配送订单{orderid}" + ) + + # 更新订单支付信息 + order.prepay_id = resp_data.get("prepay_id") + db.commit() + + # 生成支付参数 + timestamp = str(int(time.time())) + nonce_str = generate_random_string() + package = f"prepay_id={resp_data.get('prepay_id')}" + + # 构建签名数据 + sign_data = f"{settings.WECHAT_APPID}\n{timestamp}\n{nonce_str}\n{package}\n" + signature = wx_pay_client.sign(sign_data.encode()) + + return success_response(data={ + "orderid": orderid, + "payment_params": { + "appId": settings.WECHAT_APPID, + "timeStamp": timestamp, + "nonceStr": nonce_str, + "package": package, + "signType": "RSA", + "paySign": signature + } + }) + + except Exception as e: + db.rollback() + return error_response(code=500, message=f"创建支付订单失败: {str(e)}") + +@router.post("/pay/notify") +async def wechat_pay_notify(request: Request): + """微信支付回调通知""" + try: + # 获取微信支付客户端 + wx_pay_client = WeChatClient() + + # 读取原始请求数据 + body = await request.body() + + # 验证签名 + headers = request.headers + signature = headers.get("Wechatpay-Signature") + timestamp = headers.get("Wechatpay-Timestamp") + nonce = headers.get("Wechatpay-Nonce") + serial_no = headers.get("Wechatpay-Serial") + + if not all([signature, timestamp, nonce, serial_no]): + return error_response(code=400, message="缺少必要的请求头") + + # 验证签名 + sign_str = f"{timestamp}\n{nonce}\n{body.decode()}\n" + if not wx_pay_client.verify_signature( + sign_str.encode(), + signature, + serial_no + ): + return error_response(code=401, message="签名验证失败") + + # 解密数据 + data = json.loads(body) + resource = data.get("resource") + if not resource: + return error_response(code=400, message="缺少资源数据") + + # 解密回调数据 + decrypted_data = wx_pay_client.decrypt_callback( + resource.get("associated_data", ""), + resource.get("nonce", ""), + resource.get("ciphertext", "") + ) + + # 解析解密后的数据 + notify_data = json.loads(decrypted_data) + + # 获取订单信息 + trade_state = notify_data.get("trade_state") + orderid = notify_data.get("out_trade_no") + transaction_id = notify_data.get("transaction_id") + + # 处理支付结果 + if trade_state == "SUCCESS": + # 获取数据库会话 + db = next(get_db()) + try: + # 查询并更新订单 + order = db.query(ShippingOrderDB).filter( + ShippingOrderDB.orderid == orderid, + ShippingOrderDB.pay_status == False # 避免重复处理 + ).first() + + if order: + # 更新订单支付状态 + order.pay_status = True + order.pay_time = datetime.now(timezone.utc) + order.transaction_id = transaction_id + + db.commit() + + return success_response(message="支付成功") + + except Exception as e: + db.rollback() + # 记录错误日志 + print(f"处理支付回调失败: {str(e)}") + return error_response(code=500, message=f"处理失败: {str(e)}") + finally: + db.close() + + return success_response(message="回调处理成功") + + except Exception as e: + # 记录错误日志 + print(f"支付回调异常: {str(e)}") + return error_response(code=500, message=f"回调处理异常: {str(e)}") \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index d4c4d91..244af76 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -58,7 +58,10 @@ class Settings(BaseSettings): WECHAT_APPID: str = "wx3cc5b7dcb28f2756" WECHAT_SECRET: str = "fdf03e0ff428097c2a264da50b7d804e" - + WECHAT_MCH_ID: str = "1688852888" + WECHAT_PRIVATE_KEY_PATH: str = "app/core/wechat_private_key.pem" + WECHAT_CERT_SERIAL_NO: str = "1688852888" + class Config: case_sensitive = True env_file = ".env" diff --git a/app/core/wechat.py b/app/core/wechat.py index fd76c32..8f85ceb 100644 --- a/app/core/wechat.py +++ b/app/core/wechat.py @@ -1,5 +1,19 @@ import aiohttp from app.core.config import settings +import requests +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +import json +import base64 +import time +import random +import string + +def generate_random_string(length=32): + """生成指定长度的随机字符串""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + class WeChatClient: """微信客户端""" @@ -7,8 +21,53 @@ class WeChatClient: def __init__(self): self.appid = settings.WECHAT_APPID self.secret = settings.WECHAT_SECRET + self.mchid = settings.WECHAT_MCH_ID + self.private_key = self._load_private_key() + self.cert_serial_no = settings.WECHAT_CERT_SERIAL_NO self.access_token = None + def _load_private_key(self): + """加载商户私钥""" + with open(settings.WECHAT_PRIVATE_KEY_PATH, 'rb') as f: + return serialization.load_pem_private_key( + f.read(), + password=None + ) + + def sign(self, data: bytes) -> str: + """签名数据""" + signature = self.private_key.sign( + data, + padding.PKCS1v15(), + hashes.SHA256() + ) + return base64.b64encode(signature).decode() + + def post(self, path: str, **kwargs) -> requests.Response: + """发送 POST 请求到微信支付接口""" + url = f"https://api.mch.weixin.qq.com{path}" + timestamp = str(int(time.time())) + nonce = generate_random_string() + + # 准备签名数据 + body = kwargs.get('json', '') + body_str = json.dumps(body) if body else '' + sign_str = f"POST\n{path}\n{timestamp}\n{nonce}\n{body_str}\n" + + # 计算签名 + signature = self.sign(sign_str.encode()) + + # 设置认证信息 + auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce}",signature="{signature}",timestamp="{timestamp}",serial_no="{self.cert_serial_no}"' + + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': auth + } + + return requests.post(url, headers=headers, **kwargs) + async def get_access_token(self): """获取接口调用凭证""" if self.access_token: @@ -41,4 +100,36 @@ class WeChatClient: result = await response.json() if result.get("errcode") == 0: return result.get("phone_info") - raise Exception(result.get("errmsg", "获取手机号失败")) \ No newline at end of file + raise Exception(result.get("errmsg", "获取手机号失败")) + + def create_jsapi_payment(self, orderid: str, amount: int, openid: str, description: str) -> dict: + """创建 JSAPI 支付订单 + + Args: + orderid: 订单号 + amount: 支付金额(分) + openid: 用户openid + description: 商品描述 + """ + pay_data = { + "appid": settings.WECHAT_APPID, + "mchid": settings.WECHAT_MCH_ID, + "description": description, + "out_trade_no": orderid, + "notify_url": f"{settings.API_BASE_URL}/api/wechat/pay/notify", + "amount": { + "total": amount, + "currency": "CNY" + }, + "payer": { + "openid": openid + } + } + + # 调用微信支付API + resp = self.post("/v3/pay/transactions/jsapi", json=pay_data) + + if resp.status_code != 200: + raise Exception(f"微信支付下单失败: {resp.json().get('message')}") + + return resp.json() \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py index 96d5302..f939eeb 100644 --- a/app/models/order.py +++ b/app/models/order.py @@ -1,6 +1,6 @@ from datetime import datetime from typing import Optional, List -from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, Enum +from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, Enum, Boolean from sqlalchemy.sql import func from pydantic import BaseModel, Field from .database import Base @@ -41,6 +41,10 @@ class ShippingOrderDB(Base): ) deliveryman_user_id = Column(Integer, ForeignKey("users.userid"), nullable=True) cancel_user_id = Column(Integer, ForeignKey("users.userid"), nullable=True) + pay_status = Column(Boolean, default=False) # 支付状态 + prepay_id = Column(String(64)) # 微信支付预支付ID + pay_time = Column(DateTime(timezone=True)) # 支付时间 + transaction_id = Column(String(64)) # 微信支付交易号 class ShippingOrderPackageDB(Base): __tablename__ = "shipping_order_packages" diff --git a/requirements.txt b/requirements.txt index 981a9f2..80faa1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ SQLAlchemy==2.0.27 unisms cos-python-sdk-v5==1.9.25 bcrypt -aiohttp==3.9.1 \ No newline at end of file +aiohttp==3.9.1 +cryptography==42.0.2 \ No newline at end of file