From aae9b5e7dfb943f8af855004771a289483b6f0eb Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 23 Jan 2025 21:08:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BE=AE=E4=BF=A1=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/wechat.py | 118 ++++++++++++++++++++- app/cert/apiclient_cert.pem | 25 +++++ app/cert/apiclient_key.pem | 28 +++++ app/core/config.py | 10 +- app/core/wechat.py | 197 ++++++++++++++++++++++++++++++++++++ 5 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 app/cert/apiclient_cert.pem create mode 100644 app/cert/apiclient_key.pem diff --git a/app/api/endpoints/wechat.py b/app/api/endpoints/wechat.py index 3149ff4..fd1b998 100644 --- a/app/api/endpoints/wechat.py +++ b/app/api/endpoints/wechat.py @@ -6,7 +6,7 @@ from app.models.order import ShippingOrderDB, OrderStatus from app.core.response import success_response, error_response, ResponseModel from app.core.wechat import WeChatClient,generate_random_string from app.core.security import create_access_token, set_jwt_cookie -from pydantic import BaseModel +from pydantic import BaseModel, Field import json import time from datetime import datetime, timezone @@ -14,6 +14,8 @@ from app.api.deps import get_current_user from app.core.config import settings import random import string +from app.models.merchant_order import MerchantOrderDB, MerchantOrderStatus +from app.models.merchant_pay_order import MerchantPayOrderDB, MerchantPayOrderStatus router = APIRouter() @@ -22,6 +24,11 @@ class PhoneNumberRequest(BaseModel): phone_code: str # 手机号验证码 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) async def wechat_phone_login( request: PhoneNumberRequest, @@ -96,4 +103,111 @@ 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("/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)}") \ No newline at end of file diff --git a/app/cert/apiclient_cert.pem b/app/cert/apiclient_cert.pem new file mode 100644 index 0000000..949934e --- /dev/null +++ b/app/cert/apiclient_cert.pem @@ -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----- diff --git a/app/cert/apiclient_key.pem b/app/cert/apiclient_key.pem new file mode 100644 index 0000000..eaaa530 --- /dev/null +++ b/app/cert/apiclient_key.pem @@ -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----- diff --git a/app/core/config.py b/app/core/config.py index e0fc5cc..bd5f67c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -54,11 +54,11 @@ 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" - WECHAT_API_V3_KEY: str = "your-api-v3-key" # API v3密钥 - WECHAT_PLATFORM_CERT_PATH: str = "app/core/wechat_platform_cert.pem" # 平台证书路径 + WECHAT_MCH_ID: str = "1705259837" + WECHAT_PRIVATE_KEY_PATH: str = "app/cert/apiclient_key.pem" + WECHAT_CERT_SERIAL_NO: str = "5958C6605B4651122EC364831A3011C047EE549C" + WECHAT_API_V3_KEY: str = "OAhAqXqebeT4ZC9VTYFkSWU0CENEahx5" # API v3密钥 + WECHAT_PLATFORM_CERT_PATH: str = "app/cert/apiclient_cert.pem" # 平台证书路径 diff --git a/app/core/wechat.py b/app/core/wechat.py index 28dc660..8d4cdb6 100644 --- a/app/core/wechat.py +++ b/app/core/wechat.py @@ -11,6 +11,7 @@ import random import string from cryptography.x509 import load_pem_x509_certificate from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import uuid def generate_random_string(length=32): """生成指定长度的随机字符串""" @@ -23,6 +24,23 @@ class WeChatClient: def __init__(self): self.appid = settings.WECHAT_APPID 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): """获取小程序全局接口调用凭据""" @@ -100,4 +118,183 @@ class WeChatClient: 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)}") + \ No newline at end of file