增加微信支付的接口

This commit is contained in:
aaron 2025-01-23 21:08:05 +08:00
parent ef3814283d
commit aae9b5e7df
5 changed files with 371 additions and 7 deletions

View File

@ -6,7 +6,7 @@ from app.models.order import ShippingOrderDB, OrderStatus
from app.core.response import success_response, error_response, ResponseModel from app.core.response import success_response, error_response, ResponseModel
from app.core.wechat import WeChatClient,generate_random_string from app.core.wechat import WeChatClient,generate_random_string
from app.core.security import create_access_token, set_jwt_cookie from app.core.security import create_access_token, set_jwt_cookie
from pydantic import BaseModel from pydantic import BaseModel, Field
import json import json
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@ -14,6 +14,8 @@ from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
import random import random
import string import string
from app.models.merchant_order import MerchantOrderDB, MerchantOrderStatus
from app.models.merchant_pay_order import MerchantPayOrderDB, MerchantPayOrderStatus
router = APIRouter() router = APIRouter()
@ -22,6 +24,11 @@ class PhoneNumberRequest(BaseModel):
phone_code: str # 手机号验证码 phone_code: str # 手机号验证码
referral_code: str = None # 推荐码(可选) referral_code: str = None # 推荐码(可选)
class WechatPayRequest(BaseModel):
"""微信支付请求"""
order_id: str
order_type: str = Field(..., description="订单类型: merchant_order/merchant_pay_order")
@router.post("/phone-login", response_model=ResponseModel) @router.post("/phone-login", response_model=ResponseModel)
async def wechat_phone_login( async def wechat_phone_login(
request: PhoneNumberRequest, request: PhoneNumberRequest,
@ -96,4 +103,111 @@ async def wechat_phone_login(
) )
except Exception as e: except Exception as e:
db.rollback() db.rollback()
return error_response(code=500, message=f"登录失败: {str(e)}") return error_response(code=500, message=f"登录失败: {str(e)}")
@router.post("/create-payment", response_model=ResponseModel)
async def create_payment(
request: WechatPayRequest,
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_current_user)
):
"""创建微信支付订单"""
# 查询订单
if request.order_type == "merchant_order":
order = db.query(MerchantOrderDB).filter(
MerchantOrderDB.order_id == request.order_id,
MerchantOrderDB.user_id == current_user.userid,
MerchantOrderDB.status == MerchantOrderStatus.CREATED
).first()
if not order:
return error_response(code=404, message="订单不存在或状态不正确")
amount = order.pay_amount
description = "商家商品订单"
elif request.order_type == "merchant_pay_order":
order = db.query(MerchantPayOrderDB).filter(
MerchantPayOrderDB.order_id == request.order_id,
MerchantPayOrderDB.user_id == current_user.userid,
MerchantPayOrderDB.status == MerchantPayOrderStatus.UNPAID
).first()
if not order:
return error_response(code=404, message="订单不存在或状态不正确")
amount = order.amount
description = "商家在线买单"
else:
return error_response(code=400, message="不支持的订单类型")
try:
# 初始化微信支付客户端
wechat = WeChatClient()
# 创建支付订单
result = await wechat.create_jsapi_payment(
openid=current_user.openid,
out_trade_no=request.order_id,
total_amount=int(float(amount) * 100), # 转换为分
description=description
)
if not result:
return error_response(code=500, message="创建支付订单失败")
return success_response(data={
"prepay_id": result.get("prepay_id"),
"payment_params": result.get("payment_params")
})
except Exception as e:
return error_response(code=500, message=f"创建支付订单失败: {str(e)}")
@router.post("/payment-notify")
async def payment_notify(
request: Request,
db: Session = Depends(get_db)
):
"""微信支付回调通知"""
try:
# 初始化微信支付客户端
wechat = WeChatClient()
# 验证并解析回调数据
data = await wechat.verify_payment_notify(request)
if not data:
return error_response(code=400, message="回调数据验证失败")
# 获取订单信息
out_trade_no = data.get("out_trade_no")
transaction_id = data.get("transaction_id")
trade_state = data.get("trade_state")
success_time = data.get("success_time")
if trade_state != "SUCCESS":
return error_response(code=400, message="支付未成功")
# 更新订单状态
if out_trade_no.startswith("M"): # 商家商品订单
order = db.query(MerchantOrderDB).filter(
MerchantOrderDB.order_id == out_trade_no
).first()
if order:
order.status = MerchantOrderStatus.UNVERIFIED
order.pay_time = datetime.fromisoformat(success_time)
order.transaction_id = transaction_id
elif out_trade_no.startswith("P"): # 商家在线买单
order = db.query(MerchantPayOrderDB).filter(
MerchantPayOrderDB.order_id == out_trade_no
).first()
if order:
order.status = MerchantPayOrderStatus.PAID
order.pay_time = datetime.fromisoformat(success_time)
order.transaction_id = transaction_id
db.commit()
return success_response(message="支付成功")
except Exception as e:
db.rollback()
return error_response(code=500, message=f"处理支付回调失败: {str(e)}")

View File

@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEJDCCAwygAwIBAgIUWVjGYFtGURIuw2SDGjARwEfuVJwwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
Q0EwHhcNMjUwMTIzMTI1MTEyWhcNMzAwMTIyMTI1MTEyWjB+MRMwEQYDVQQDDAox
NzA1MjU5ODM3MRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xKjAoBgNVBAsM
IeaIkOmDveeIseWYiei+sOenkeaKgOaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04x
ETAPBgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEAtwr4/6MeJ/YCFdQRG8qH/lv8TqXb/76NoPyj/pOgNuck/kQl/H9hf4kM74z2
k7uR56q5bs8NteIHGpLg0Zo5QGMHWnAHamnYu5Sl7SLfIVHByKSWeG0BgIdvJiyO
6PkpOyohMqbr55s4+eK6yLkblCaYF/dLE429W8UUoSC79BgVvt+YMWO75lX8OOLI
Syz9MZSzm7j7N5ri3exa7BQ+5+j5a97IVZFUxWsHfmWMFRPVX+J7SrLkCeU3uDMo
ltKaxGZ5Of3RmzodrgYZPNFbDkufFxnJMWKGSQtcs0wbUSTL1L7+OJ9e5E7huht2
di5Lndgq92z7b6CvmS1afQKpRwIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0P
BAQDAgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1
cy5jb20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFE
Mzk3NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMz
QTg3QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBAKV4YtDUPdqW
8NsWKm5NbIlL8LJBy2s7QcnGx8dWU48ykqn52VIy+HvcVHqwL/bWgHoH3/KQYlwk
VmZeuGxiC+0bEcDxCUN8b48DFpcbiQKMvo1J8HAEGm1XAMZn5EZDapCNTzCADJf9
a2A+GG6j6AlnkA3RRMWS0PhnZDXPkoBae/zRx9x+bBkltCA+YoUGAaNJFfEgOPkZ
5rLD6YBlOC+5BX9gCXZs5efi1aFxLbhQoz/+pJ1zwz5ka231B0t0FPOKBek1Pcu1
sZR/cOaen5Sd0p6C09da2WQT8uOD5XaEFZXI7KxrRPJ1RRFhLh8fc1bKR584rpsh
hLL4HOEbk1c=
-----END CERTIFICATE-----

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3Cvj/ox4n9gIV
1BEbyof+W/xOpdv/vo2g/KP+k6A25yT+RCX8f2F/iQzvjPaTu5Hnqrluzw214gca
kuDRmjlAYwdacAdqadi7lKXtIt8hUcHIpJZ4bQGAh28mLI7o+Sk7KiEypuvnmzj5
4rrIuRuUJpgX90sTjb1bxRShILv0GBW+35gxY7vmVfw44shLLP0xlLObuPs3muLd
7FrsFD7n6Plr3shVkVTFawd+ZYwVE9Vf4ntKsuQJ5Te4MyiW0prEZnk5/dGbOh2u
Bhk80VsOS58XGckxYoZJC1yzTBtRJMvUvv44n17kTuG6G3Z2Lkud2Cr3bPtvoK+Z
LVp9AqlHAgMBAAECggEBAJk+ooDDu/eQyuYjib9OrNSThoUB71IJ4uEpItN8HOJa
WmpV+8eNjb8MqrvTtIyyuNDP6jePOddQyMnCtl5FVDFHt1xL9qlsvHsvVEtYqp5m
qGqnASMJf/xvZur62xrJn29dMjYJ8e8R0X3ECMUL1L8QIL3P2Bciz6oJMeBEW5db
N/cTw4lbF05DeMHlPNAX+uog0e5qoRB/l5BxlT+n40IpcFNTLPRhJurgAPpTijkb
EE0+K9v/9l5RG8xEX56MCChIHeCE3bNnVFPNzS6mtoDWu9OdwJ5PpdYG4LekDkfQ
RgorIYCEhJ2yaZroaTn5FY3dahSGr7VVlqOAH2QGVIECgYEA4WCiyEEt8nPYN5Am
LFG6/GHY7dzem7jY70AOuGAsSsX8dB1xfc3itsdqpm5XAWAQ1Pb5m/DWjlnL53p6
YEF83H8r1jV5oPgyWBMoQAg1zpxjo6APz8d9msfeHF7KUfYvj3EE1TcrM+YY6dAq
H1voLXCRguj9tIb+EebzOUxDGrcCgYEAz+nM7ygACNIDmt3JSZb+k8qL4grKQCoB
iTzQHXR/VYZm8C+F+x8CpCHW5F1RsEqSHxJW2SUQX+xYa/fFrd/Q2aqMjqJ8UpCa
7kHHPjUX5QWBgDQpg3tmGrbPkauVtaqeRsYAPb3r9dSdlqepPpsOaT6GgLSuTP4l
x41a1RUGlfECgYAucVx6CbxvJuIaaREEtv7iPUOXmJki28+QVdHyupbF/dCNGPgn
JYMfiS54B2rUdLhjOlWrhdCg2u5C0CFhrn0NbwNYjAJ5Ykv1jFUSBN8ZqW567GP1
vDUs7RzfGcV1aFbapz6ItWqosjTWEbhsZ+MLYhQKNvr49YxrofzjBM0bNwKBgQCq
PlhHL+qvTkATbC2o61GzdHOL+KfZWEv/suL6a2zke/QIEfHUSXUhLnBGd78u6jCx
7pNcpMO+t8lDRxP/prfds4/6L0Q7WxrxorzhzBmvtw1uC8g+WCmoEC7wqZ4hrf6C
FxkVdVEj7x/Gv6yOjeqD9OWvt8LNWoFW4AETX28QEQKBgC2cpAtb0zbPDEqXbnod
NmC5W34AJDmVvah9w9SZyQeK4RHkYU604qg0aBEuKtkV9sNxolLydnfn/eqyepjk
8XwrEU/2EI9t8uGQMB+TjL/Z7eYoBYVyaugbksyEZo6heD2NvHwGPl1c4s0O/gsY
R1k9gdf+oEAcZVVH4V7367Ou
-----END PRIVATE KEY-----

View File

@ -54,11 +54,11 @@ class Settings(BaseSettings):
WECHAT_APPID: str = "wx3cc5b7dcb28f2756" WECHAT_APPID: str = "wx3cc5b7dcb28f2756"
WECHAT_SECRET: str = "fdf03e0ff428097c2a264da50b7d804e" WECHAT_SECRET: str = "fdf03e0ff428097c2a264da50b7d804e"
WECHAT_MCH_ID: str = "1688852888" WECHAT_MCH_ID: str = "1705259837"
WECHAT_PRIVATE_KEY_PATH: str = "app/core/wechat_private_key.pem" WECHAT_PRIVATE_KEY_PATH: str = "app/cert/apiclient_key.pem"
WECHAT_CERT_SERIAL_NO: str = "1688852888" WECHAT_CERT_SERIAL_NO: str = "5958C6605B4651122EC364831A3011C047EE549C"
WECHAT_API_V3_KEY: str = "your-api-v3-key" # API v3密钥 WECHAT_API_V3_KEY: str = "OAhAqXqebeT4ZC9VTYFkSWU0CENEahx5" # API v3密钥
WECHAT_PLATFORM_CERT_PATH: str = "app/core/wechat_platform_cert.pem" # 平台证书路径 WECHAT_PLATFORM_CERT_PATH: str = "app/cert/apiclient_cert.pem" # 平台证书路径

View File

@ -11,6 +11,7 @@ import random
import string import string
from cryptography.x509 import load_pem_x509_certificate from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import uuid
def generate_random_string(length=32): def generate_random_string(length=32):
"""生成指定长度的随机字符串""" """生成指定长度的随机字符串"""
@ -23,6 +24,23 @@ class WeChatClient:
def __init__(self): def __init__(self):
self.appid = settings.WECHAT_APPID self.appid = settings.WECHAT_APPID
self.secret = settings.WECHAT_SECRET self.secret = settings.WECHAT_SECRET
self.mch_id = settings.WECHAT_MCH_ID
self.private_key_path = settings.WECHAT_PRIVATE_KEY_PATH
self.cert_serial_no = settings.WECHAT_CERT_SERIAL_NO
self.api_v3_key = settings.WECHAT_API_V3_KEY
self.platform_cert_path = settings.WECHAT_PLATFORM_CERT_PATH
# 加载商户私钥
with open(self.private_key_path, "rb") as f:
self.private_key = serialization.load_pem_private_key(
f.read(),
password=None
)
# 加载平台证书
with open(self.platform_cert_path, "rb") as f:
self.platform_cert = load_pem_x509_certificate(f.read())
self.platform_public_key = self.platform_cert.public_key()
async def get_access_token(self): async def get_access_token(self):
"""获取小程序全局接口调用凭据""" """获取小程序全局接口调用凭据"""
@ -100,4 +118,183 @@ class WeChatClient:
return result return result
def sign_message(self, method: str, url_path: str, body: dict) -> tuple:
"""生成请求签名
Returns:
tuple: (nonce_str, timestamp, signature)
"""
nonce_str = str(uuid.uuid4()).replace('-', '')
timestamp = str(int(time.time()))
# 构造签名字符串
sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce_str}\n"
if body:
sign_str += f"{json.dumps(body)}\n"
else:
sign_str += "\n"
# 使用私钥签名
signature = self.private_key.sign(
sign_str.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return nonce_str, timestamp, base64.b64encode(signature).decode()
def verify_response(self, headers: dict, body: bytes) -> bool:
"""验证响应签名"""
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
signature = headers.get('Wechatpay-Signature')
serial = headers.get('Wechatpay-Serial')
if not all([timestamp, nonce, signature, serial]):
return False
# 构造验签字符串
sign_str = f"{timestamp}\n{nonce}\n{body.decode('utf-8')}\n"
try:
# 验证签名
self.platform_public_key.verify(
base64.b64decode(signature),
sign_str.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception:
return False
async def create_jsapi_payment(
self,
openid: str,
out_trade_no: str,
total_amount: int,
description: str
) -> dict:
"""创建 JSAPI 支付订单"""
url_path = "/v3/pay/transactions/jsapi"
api_url = f"https://api.mch.weixin.qq.com{url_path}"
# 构建请求数据
body = {
"appid": self.appid,
"mchid": self.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": f"{settings.API_BASE_URL}/api/wechat/payment-notify",
"amount": {
"total": total_amount,
"currency": "CNY"
},
"payer": {
"openid": openid
}
}
# 生成签名
nonce_str, timestamp, signature = self.sign_message("POST", url_path, body)
# 构建认证头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce_str}",'
f'timestamp="{timestamp}",'
f'serial_no="{self.cert_serial_no}",'
f'signature="{signature}"'
)
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(api_url, json=body, headers=headers) as response:
result = await response.json()
if response.status != 200:
raise Exception(f"请求失败: {result.get('message')}")
# 验证响应签名
if not self.verify_response(response.headers, await response.read()):
raise Exception("响应签名验证失败")
# 生成小程序调起支付的参数
prepay_id = result.get("prepay_id")
if not prepay_id:
raise Exception("未获取到prepay_id")
timestamp = str(int(time.time()))
nonce_str = str(uuid.uuid4()).replace('-', '')
package = f"prepay_id={prepay_id}"
# 签名支付参数
sign_str = f"{self.appid}\n{timestamp}\n{nonce_str}\n{package}\n"
signature = base64.b64encode(
self.private_key.sign(
sign_str.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
).decode()
return {
"prepay_id": prepay_id,
"payment_params": {
"appId": self.appid,
"timeStamp": timestamp,
"nonceStr": nonce_str,
"package": package,
"signType": "RSA",
"paySign": signature
}
}
except Exception as e:
raise Exception(f"创建支付订单失败: {str(e)}")
async def verify_payment_notify(self, request: Request) -> dict:
"""验证支付回调通知"""
# 获取请求头
headers = {
'Wechatpay-Signature': request.headers.get('Wechatpay-Signature'),
'Wechatpay-Timestamp': request.headers.get('Wechatpay-Timestamp'),
'Wechatpay-Nonce': request.headers.get('Wechatpay-Nonce'),
'Wechatpay-Serial': request.headers.get('Wechatpay-Serial')
}
# 读取请求体
body = await request.body()
# 验证签名
if not self.verify_response(headers, body):
raise Exception("回调通知签名验证失败")
# 解析数据
try:
data = json.loads(body)
resource = data.get('resource', {})
# 解密数据
nonce = base64.b64decode(resource['nonce'])
associated_data = resource.get('associated_data', '').encode('utf-8')
ciphertext = base64.b64decode(resource['ciphertext'])
aesgcm = AESGCM(self.api_v3_key.encode('utf-8'))
decrypted_data = aesgcm.decrypt(
nonce,
ciphertext,
associated_data
)
return json.loads(decrypted_data)
except Exception as e:
raise Exception(f"解析回调数据失败: {str(e)}")