from fastapi import APIRouter, Depends, HTTPException, status, Request, Response from fastapi.responses import StreamingResponse, JSONResponse from fastapi.security import OAuth2PasswordBearer import hashlib import random import string import xml.etree.ElementTree as ET import requests import time from datetime import datetime, timedelta, date from dateutil.relativedelta import relativedelta from decimal import Decimal from uuid import UUID import json import logging from app.database import * from jose import JWTError, jwt import base64 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.backends import default_backend import httpx,threading,asyncio class CustomJSONResponse(JSONResponse): """ 自定义 JSON 响应类,处理特殊类型: - datetime: 转换为 ISO 8601 字符串 - date: 转换为 ISO 8601 字符串 - Decimal: 转换为 float """ def render(self, content: any) -> bytes: """ 重写渲染方法,使用自定义编码器 """ class EnhancedJSONEncoder(json.JSONEncoder): def default(self, obj): """ 增强型 JSON 编码器,处理多种特殊类型: - datetime: 转换为 ISO 8601 字符串 - date: 转换为 ISO 8601 字符串 - time: 转换为 ISO 8601 字符串 - Decimal: 转换为 float - UUID: 转换为字符串 - numpy 类型: 转换为 Python 原生类型 """ # 处理日期时间类型 if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, date): return obj.isoformat() # 处理 Decimal 类型 if isinstance(obj, Decimal): return float(obj) # 处理 UUID 类型 if isinstance(obj, UUID): return str(obj) """ # 处理 numpy 类型 if isinstance(obj, np.integer): return int(obj) if isinstance(obj, np.floating): return float(obj) if isinstance(obj, np.ndarray): return obj.tolist() """ # 处理其他自定义类型 if hasattr(obj, '__json__'): return obj.__json__() # 默认处理 return super().default(obj) return json.dumps( content, ensure_ascii=False, allow_nan=False, indent=None, separators=(",", ":"), cls=EnhancedJSONEncoder ).encode("utf-8") payment_router = APIRouter() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") logger = logging.getLogger("payment") # 微信支付配置 WX_APPID = "wx446813bfb3a6985a" WX_APPSECRET= "a7455fca777ad59ce96cc154d62f795f" WX_MCH_ID = "1721301006" WX_PAY_KEY = "7xK9pR2qY5vN3zW8bL1cD4fG6hJ7mQ0t" WX_PAY_KEY_V3 = "7xK9pR2qY5vN3zW8bL1cD4fG6hJ7mQ0t" WX_MCH_CERT_SERIAL_NO = "6292D0A4D7B092E361D6DD22C2FD7831479D48D9" # 证书序列号 v3使用 WX_PAY_NOTIFY_URL = "https://ragflow.szzysztech.com/apitest2/payment/wx_notify" JWT_SECRET_KEY = "3e5b8d7f1a9c2b6d4e0f1a9c2b6d4e0f1a9c2b6d4e0f1a9c2b6d4e0f1a9c2b6d" ALGORITHM = "HS256" WX_PLATFORM_CERTS = {} # 加载商户私钥 def load_private_key(): """ 从PEM格式文件加载商户私钥 假设私钥文件名为 apiclient_key.pem 并位于项目根目录 """ try: # 从文件加载 - 实际部署时建议使用环境变量或安全存储 key_path = "./payment_cert/apiclient_key.pem" with open(key_path, "rb") as key_file: private_key = serialization.load_pem_private_key( key_file.read(), password=None, backend=default_backend() ) return private_key except Exception as e: logging.error(f"加载商户私钥失败: {str(e)}") # 备用方案:从环境变量加载 key_str = os.getenv("WX_MCH_PRIVATE_KEY") if key_str: return serialization.load_pem_private_key( key_str.encode(), password=None, backend=default_backend() ) raise Exception("无法加载微信支付商户私钥") # 全局加载一次私钥 try: MERCHANT_PRIVATE_KEY = load_private_key() except Exception as e: logging.error(f"初始化商户私钥失败: {str(e)}") MERCHANT_PRIVATE_KEY = None def get_wx_platform_public_key(serial_no: str): """获取微信平台公钥""" if serial_no in WX_PLATFORM_CERTS: return WX_PLATFORM_CERTS[serial_no] # 实际应用中应从微信API获取证书,这里简化处理 # 建议实现自动下载证书的机制 cert_path = "./payment_cert/apiclient_key.pem" try: with open(cert_path, "rb") as cert_file: public_key = serialization.load_pem_public_key( cert_file.read(), backend=default_backend() ) WX_PLATFORM_CERTS[serial_no] = public_key return public_key except Exception as e: logger.error(f"加载平台证书失败: {str(e)}") return None class WechatTokenManager: _instance = None _lock = threading.Lock() def __new__(cls, appid: str, secret: str): with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.appid = appid cls._instance.secret = secret cls._instance.access_token = None cls._instance.expires_at = 0 # 过期时间戳 cls._instance.refresh_margin = 300 # 提前5分钟刷新 return cls._instance async def _request_token(self) -> dict: url = "https://api.weixin.qq.com/cgi-bin/token" params = { "grant_type": "client_credential", "appid": self.appid, "secret": self.secret } async with httpx.AsyncClient() as client: try: resp = await client.get(url, params=params, timeout=10) resp.raise_for_status() data = resp.json() if "access_token" not in data: error_msg = f"Wechat API error: {data.get('errcode', '')} - {data.get('errmsg', 'Unknown error')}" logging.error(error_msg) raise HTTPException(status_code=500, detail=error_msg) return data except (httpx.RequestError, httpx.HTTPStatusError) as e: logging.error(f"Token request failed: {str(e)}") raise HTTPException(status_code=503, detail="Wechat service unavailable") async def get_token(self) -> str: current_time = time.time() # 检查是否需要刷新 (包含安全边际) if self.access_token and current_time < (self.expires_at - self.refresh_margin): return self.access_token # 双检锁避免重复刷新 with self._lock: if self.access_token and current_time < (self.expires_at - self.refresh_margin): return self.access_token # 请求新token token_data = await self._request_token() self.access_token = token_data["access_token"] self.expires_at = current_time + token_data["expires_in"] logging.info(f"Token refreshed, expires at: {time.ctime(self.expires_at)}") return self.access_token # 初始化单例 wechat_server_token_manager = WechatTokenManager(WX_APPID, WX_APPSECRET) async def get_current_user(token: str = Depends(oauth2_scheme)): """ 可选用户依赖项(不抛出401错误) 返回: 用户对象 或 None """ try: payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) user_id = payload.get("sub") return get_user_by_id(user_id) except (JWTError, StopIteration): return None @payment_router.post("/create_order") async def create_order_api( request: Request, current_user: dict = Depends(get_current_user) ): if not current_user: raise HTTPException(status_code=401, detail="请先登录") data = await request.json() museum_id = data.get("museum_id") sub_id = data.get("sub_id") # 参数名改为 subscription_id # 获取博物馆订阅信息 museum_subscription = get_museum_subscription_by_id(sub_id) if not museum_subscription: raise HTTPException(status_code=404, detail="订阅不存在") # 免费订阅直接激活 if museum_subscription["price"] == 0: order_id = f"FREE_{int(time.time())}" # 创建订单记录 - 使用数据库辅助函数 create_order({ "order_id": order_id, "user_id": current_user["user_id"], "museum_subscription_id": subscription_id, "amount": 0, "status": "activated", "create_date": datetime.now() }) # 激活订阅 - 使用数据库辅助函数 activate_user_subscription( user_id=current_user["user_id"], museum_subscription_id=sub_id, order_id=order_id ) return JSONResponse({ "code": 0, "msg": "订阅激活成功", "data": {"order_id": order_id} }) # 付费订阅创建待支付订单 order_id = f"hxb_{int(time.time())}{random.randint(1000, 9999)}" # 创建订单记录 - 使用数据库辅助函数 create_order({ "order_id": order_id, "user_id": current_user["user_id"], "museum_subscription_id": sub_id, "amount": museum_subscription["price"], "status": "created", "create_date": datetime.now() }) return JSONResponse({ "code": 0, "msg": "订单创建成功", "data": { "order_id": order_id, "amount": float(museum_subscription["price"]) } }) # 获取支付参数 @payment_router.post("/prepay") async def get_payment_params( request: Request, current_user: dict = Depends(get_current_user) ): try: data = await request.json() order_id = data.get("order_id") version = data.get("version", "v2") # 默认为 v3 if not order_id: raise HTTPException(status_code=400, detail="缺少order_id参数") # 获取订单信息 - 使用数据库辅助函数 order = get_order_by_id(order_id = order_id) if not order: raise HTTPException(status_code=404, detail="订单不存在") if order["user_id"] != current_user["user_id"]: raise HTTPException(status_code=403, detail="无权访问此订单") if order["status"] != "created": raise HTTPException(status_code=400, detail="订单状态异常") # 获取用户openid user = get_user_by_id(current_user["user_id"]) if not user or not user.get("openid"): raise HTTPException(status_code=400, detail="用户信息不完整") # 获取订阅信息以获取产品名称 museum_subscription = get_museum_subscription_by_id(order["museum_subscription_id"]) if not museum_subscription: raise HTTPException(status_code=404, detail="关联订阅不存在") # 获取模板名称 template = get_subscription_template_by_id(museum_subscription["template_id"]) if not template: raise HTTPException(status_code=404, detail="订阅模板不存在") product_name = f"博物馆讲解服务-{template['name']}" amount_in_cents = int(order["amount"] * 100) # 转换为分 # 根据版本调用不同的支付接口 if version.lower() == "v2": # V2 支付 logging.info(f"使用微信支付V2接口") prepay_params = await generate_wx_prepay_params_v2( order_id=order["order_id"], total_fee=amount_in_cents, openid=user["openid"], body=product_name ) # 生成支付签名 pay_sign = generate_pay_sign( appid=WX_APPID, timestamp=prepay_params["timestamp"], noncestr=prepay_params["noncestr"], prepay_id=prepay_params["prepay_id"] ) response_data = { "appid": prepay_params["appId"], "timeStamp": prepay_params["timestamp"], "nonceStr": prepay_params["noncestr"], "package": f"prepay_id={prepay_params['prepay_id']}", "signType": "MD5", "paySign": pay_sign } else: # 默认为 v3 # V3 支付 logging.info(f"使用微信支付V3接口") prepay_params = await generate_wx_prepay_params_v3( order_id=order["order_id"], total_fee=amount_in_cents, openid=user["openid"], body=product_name ) # V3 已返回完整的支付参数 response_data = { "appId": prepay_params["appId"], "timeStamp": prepay_params["timeStamp"], "nonceStr": prepay_params["nonceStr"], "package": prepay_params["package"], "signType": "RSA", # V3 使用 RSA 签名 "paySign": prepay_params["paySign"] } return JSONResponse({ "code": 0, "msg": "success", "data":response_data }) except Exception as e: logger.error(f"获取支付参数失败: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取支付参数失败: {str(e)}") # 微信支付回调处理(兼容V2和V3) @payment_router.post("/wx_notify") async def wx_payment_notify(request: Request): headers = request.headers body = await request.body() # 判断支付版本(V3有特定头信息) if "Wechatpay-Signature" in headers: return await handle_v3_notify(headers, body) else: return await handle_v2_notify(body) async def handle_v3_notify(headers: dict, body: bytes): """处理V3回调""" # 1. 验证签名 if not verify_wx_signature_v3(headers, body): logger.warning("V3签名验证失败") return generate_json_response("FAIL", "签名验证失败") try: # 2. 解析JSON数据 data = json.loads(body) resource = data.get("resource", {}) # 3. 解密资源数据 decrypted_data = decrypt_v3_resource(resource) if not decrypted_data: logger.error("V3回调数据解密失败") return generate_json_response("FAIL", "数据解密失败") logger.info(f"V3解密数据: {json.dumps(decrypted_data, indent=2)}") # 4. 检查支付状态 if decrypted_data.get("trade_state") != "SUCCESS": logger.warning(f"支付未成功: {decrypted_data.get('trade_state_desc')}") return generate_json_response("FAIL", "支付未成功") # 5. 提取订单信息 order_id = decrypted_data.get("out_trade_no") transaction_id = decrypted_data.get("transaction_id") total_fee = float(decrypted_data["amount"]["payer_total"]) / 100 # 转换为元 # 6. 处理支付成功逻辑 success, msg = await process_payment_success(order_id, transaction_id, total_fee) if not success: return generate_json_response("FAIL", msg) return generate_json_response("SUCCESS", "OK") except Exception as e: logger.exception(f"V3回调处理异常: {str(e)}") return generate_json_response("FAIL", f"处理失败: {str(e)}") async def handle_v2_notify(body: bytes): """处理V2回调""" try: logging.info(f"V2 wx_notify回调数据: {body}") data = xml_to_dict(body.decode('utf-8')) # 1. 验证签名 if not verify_wx_signature_v2(data, WX_PAY_KEY): logger.warning("V2签名验证失败") return generate_xml_response("FAIL", "签名验证失败") # 2. 检查支付状态 if data.get("return_code") != "SUCCESS": logger.warning(f"支付失败: {data.get('return_msg')}") return generate_xml_response("FAIL", "支付失败") # 3. 提取订单信息 order_id = data.get("out_trade_no") transaction_id = data.get("transaction_id") total_fee = float(data.get("total_fee", 0)) / 100 # 转换为元 # 4. 处理支付成功逻辑 success, msg = await process_payment_success(order_id, transaction_id, total_fee) if not success: return generate_xml_response("FAIL", msg) return generate_xml_response("SUCCESS", "OK") except Exception as e: logger.exception(f"V2回调处理异常: {str(e)}") return generate_xml_response("FAIL", f"处理失败: {str(e)}") @payment_router.post("/check_payment") async def check_payment_status( request: Request, current_user: dict = Depends(get_current_user) ): if not current_user: raise HTTPException(status_code=401, detail="请先登录") data = await request.json() order_id = data.get("order_id") if not order_id: raise HTTPException(status_code=400, detail="缺少订单号") # 查询订单状态 order = get_order_by_id(order_id = order_id) if not order: return JSONResponse({"code": 404, "msg": "订单不存在"}) # 检查订单是否属于当前用户 if order["user_id"] != current_user["user_id"]: return JSONResponse({"code": 403, "msg": "无权访问此订单"}) # 返回支付状态 return JSONResponse({ "code": 0, "msg": "success", "data": { "order_id": order_id, "paid": order["status"] in ["paid", "activated","delivered"], "status": order["status"] } }) @payment_router.get("/get_subscriptions/{museum_id}") async def get_museum_subscriptions_by_museum_id( museum_id: str, request: Request, current_user: dict = Depends(get_current_user) ): result = get_museum_subscriptions_by_museum(museum_id) return CustomJSONResponse(result) @payment_router.post("/get_order_list") async def get_order_list( request: Request, current_user: dict = Depends(get_current_user) ): data = await request.json() museum_id = data.get("museum_id") result = get_order_by_id(user_id = current_user["user_id"],combined=True,museum_id=museum_id) return CustomJSONResponse({ "code": 0, "msg": "success", "data": result}) @payment_router.get("/get_order_detail/{order_id}") async def get_order_detial( order_id: str, request: Request, current_user: dict = Depends(get_current_user) ): result = get_order_by_id(order_id = order_id,combined=True) return CustomJSONResponse({ "code": 0, "msg": "success", "data": result}) # 用户是否有权访问这个博物馆 @payment_router.post("/get_user_museum_subscriptions") async def get_user_museum_subscriptions( request: Request, current_user: dict = Depends(get_current_user) ): data = await request.json() museum_id = data.get("museum_id") user_id = current_user["user_id"] # 用户id is_test_account = current_user.get('is_test_account',0) is_free = False museum_info = get_museum_by_id(museum_id=museum_id) if museum_info and museum_info['free']: is_free = True is_free_period,free_period_subscription = is_museum_free_period(museum_id) is_subscription_valid = get_user_valid_subscription(user_id, museum_id) if is_test_account: is_subscription_valid = True can_access = is_free or is_free_period or is_subscription_valid result = { 'can_access': can_access, 'is_free': is_free, 'is_free_period': is_free_period, 'is_subscription_valid': is_subscription_valid } if free_period_subscription: if free_period_subscription.get('validity_type') == 'free_interval': if free_period_subscription.get('valid_time_range'): result['valid_time_range'] = free_period_subscription.get('valid_time_range') # 增加免费时间段信息 return CustomJSONResponse({ "code": 0, "msg": "success", "data": result}) # --- 支付工具函数 --- async def generate_wx_prepay_params_v2(order_id: str, total_fee: int, openid: str, body: str): """生成微信预支付参数 - 修复签名错误""" try: # 准备请求参数 nonce_str = generate_nonce_str(32) params = { "appid": WX_APPID, "mch_id": WX_MCH_ID, "nonce_str": nonce_str, "body": body[:128], # 商品描述 "out_trade_no": order_id, "total_fee": str(total_fee), # 确保字符串格式 "spbill_create_ip": "127.0.0.1", # 终端IP "notify_url": WX_PAY_NOTIFY_URL, "trade_type": "JSAPI", "openid": openid } params1={'appid': 'wx446813bfb3a6985a', 'mch_id': '1721301006', 'nonce_str': '7qi8x7npD2S5JCD8LPV2mHEX5FgeWa1U', 'body': '博物馆讲解服务-1个月有效', 'out_trade_no': 'PAY_17515540373111', 'total_fee': '500', 'spbill_create_ip': '1.13.185.116', 'notify_url': 'https://yourdomain.com/api/payment/wx_notify', 'trade_type': 'JSAPI', 'openid': 'obKSz7V6a-avAF-vtQrnk_rnuSGE'} logging.info(f"generate_wx_prepay_params--0 {params}") # 生成签名 - 添加详细日志 sign_str_before = '&'.join([f"{k}={v}" for k, v in sorted(params.items())]) params["sign"] = generate_sign_v2(params, WX_PAY_KEY) logging.info(f"Generated sign: {params['sign']}") # 转换为XML xml_data = dict_to_xml(params) # 调用微信统一下单接口 try: response = requests.post( "https://api.mch.weixin.qq.com/pay/unifiedorder", data=xml_data.encode('utf-8'), headers={"Content-Type": "application/xml"}, timeout=10 ) #logging.info(f"微信支付接口响应状态码: {response.status_code}") #logging.info(f"微信支付接口响应内容: {response.text}") except Timeout: logging.error("微信支付接口请求超时") raise Exception("微信支付接口请求超时") except ConnectionError: logging.error("无法连接到微信支付服务器") raise Exception("网络连接失败,请检查网络设置") except RequestException as req_err: logging.error(f"微信支付请求异常: {str(req_err)}") raise Exception(f"微信支付请求失败: {str(req_err)}") # 检查HTTP响应状态 if response.status_code != 200: logging.error(f"微信支付接口返回错误状态码: {response.status_code}") raise Exception(f"微信支付接口错误: HTTP {response.status_code}") # 解析响应 response.encoding = 'utf-8' try: response_data = xml_to_dict(response.text) except Exception as parse_err: logging.error(f"解析微信响应XML失败: {parse_err}") raise Exception("微信支付返回数据格式错误") # logging.info(f"generate_wx_prepay_params--3 响应数据: {response_data}") # 检查返回结果 if response_data.get("return_code") != "SUCCESS": error_msg = response_data.get("return_msg", "未知错误") # 尝试解码可能的乱码 try: error_msg = error_msg.encode('latin1').decode('utf-8') except: pass logging.error(f"微信支付下单失败(return_code): {error_msg}") raise Exception(f"微信支付下单失败: {error_msg}") if response_data.get("result_code") != "SUCCESS": error_code = response_data.get("err_code", "") error_msg = response_data.get("err_code_des", "未知错误") # 尝试解码可能的乱码 try: error_msg = error_msg.encode('latin1').decode('utf-8') except: pass logging.error(f"微信支付下单失败(result_code): [{error_code}] {error_msg}") raise Exception(f"微信支付下单失败: {error_msg} (错误代码: {error_code})") # 检查必须的预支付ID prepay_id = response_data.get("prepay_id") if not prepay_id: logging.error("微信支付返回缺少prepay_id") raise Exception("微信支付返回数据不完整") # 返回预支付参数 return { "appId": params["appid"], "prepay_id": prepay_id, "timestamp": str(int(time.time())), "noncestr": nonce_str } except Exception as e: logging.error(f"generate_wx_prepay_params 发生异常: {str(e)}", exc_info=True) raise def generate_sign_v2(params: dict, api_key: str) -> str: """生成微信支付签名 - 修复版""" # 1. 参数按ASCII码排序 sorted_params = sorted(params.items(), key=lambda x: x[0]) # 2. 拼接成URL参数字符串(仅排除None和sign字段) sign_str = '&'.join([f"{k}={v}" for k, v in sorted_params if k != "sign" and v is not None]) # 移除非空检查 # 3. 拼接API密钥 sign_str += f"&key={api_key}" # 记录详细的签名字符串(调试用) # logging.info(f"Sign string: {sign_str}") #appid=wx446813bfb3a6985a&body=博物馆讲解服务-1个月有效&mch_id=1721301006&nonce_str=7qi8x7npD2S5JCD8LPV2mHEX5FgeWa1U¬ify_url=https://yourdomain.com/api/payment/wx_notify&openid=obKSz7V6a-avAF-vtQrnk_rnuSGE&out_trade_no=PAY_17515540373111&spbill_create_ip=127.0.0.1&total_fee=500&trade_type=JSAPI # 4. MD5加密并转为大写 md5 = hashlib.md5() md5.update(sign_str.encode('utf-8')) return md5.hexdigest().upper() def generate_pay_sign(appid: str, timestamp: str, noncestr: str, prepay_id: str) -> str: """生成支付签名 (用于小程序端)""" params = { "appId": appid, "timeStamp": timestamp, "nonceStr": noncestr, "package": f"prepay_id={prepay_id}", "signType": "MD5" } return generate_sign_v2(params, WX_PAY_KEY) def generate_nonce_str(length=32): """生成随机字符串""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def dict_to_xml(data: dict) -> str: """字典转XML - 修复特殊字符处理""" xml = "" for key, value in data.items(): # 确保所有值都转为字符串 str_value = str(value) # 对非数字类型使用CDATA包裹 if not str_value.isdigit(): xml += f"<{key}>" else: xml += f"<{key}>{str_value}" xml += "" return xml def xml_to_dict(xml_str: str) -> dict: """XML转字典 - 增强错误处理""" try: root = ET.fromstring(xml_str) result = {} for child in root: result[child.tag] = child.text return result except ET.ParseError as e: logging.error(f"XML解析失败: {e}") return {} def verify_wx_signature_v2(data: dict, api_key: str) -> bool: """验证微信签名""" if "sign" not in data: return False sign = data.pop("sign") calculated_sign = generate_sign_v2(data, api_key) return sign == calculated_sign def generate_xml_response(return_code: str, return_msg: str) -> Response: """生成支付回调XML响应""" response_data = { "return_code": return_code, "return_msg": return_msg } xml_content = dict_to_xml(response_data) return Response( content=xml_content, media_type="application/xml", headers={"Cache-Control": "no-cache"} ) def generate_json_response(code: str, message: str) -> JSONResponse: return JSONResponse( content={"code": code, "message": message}, status_code=200 ) # 新增的V3相关函数 def verify_wx_signature_v3(headers: dict, body: bytes) -> bool: """验证V3回调签名""" 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]): logger.error("V3回调缺少必要头信息") return False # 构造验签名串 sign_str = f"{timestamp}\n{nonce}\n{body.decode('utf-8')}\n" logger.debug(f"V3签名串: {sign_str}") try: # 获取微信平台公钥 public_key = get_wx_platform_public_key(serial_no) if not public_key: logger.error("无法获取微信平台公钥") return False # 验证签名 public_key.verify( base64.b64decode(signature), sign_str.encode('utf-8'), padding.PKCS1v15(), SHA256() ) return True except Exception as e: logger.error(f"V3签名验证失败: {str(e)}") return False def decrypt_v3_resource(resource: dict) -> dict: """解密V3回调资源数据""" try: ciphertext = resource.get('ciphertext') nonce = resource.get('nonce') associated_data = resource.get('associated_data', '') if not all([ciphertext, nonce]): logger.error("V3回调缺少解密参数") return {} # 使用AES-GCM解密 key_bytes = WX_PAY_KEY_V3.encode('utf-8') ciphertext_bytes = base64.b64decode(ciphertext) # 创建解密器 cipher = Cipher( algorithms.AES(key_bytes), modes.GCM(nonce.encode('utf-8'), min_tag_length=16), backend=default_backend() ) decryptor = cipher.decryptor() # 添加附加认证数据 if associated_data: decryptor.authenticate_additional_data(associated_data.encode('utf-8')) # 解密数据 decrypted = decryptor.update(ciphertext_bytes) + decryptor.finalize() return json.loads(decrypted) except Exception as e: logger.error(f"V3解密失败: {str(e)}") return {} async def process_payment_success(order_id: str, transaction_id: str, total_fee: float): """处理支付成功逻辑""" # 更新订单状态 update_order(order_id, { "status": "paid", "transaction_id": transaction_id, "pay_time": datetime.now(), "amount": total_fee }) logging.info(f"支付成功,更新订单状态 {order_id} {transaction_id} {total_fee}") # 获取订单信息 order = get_order_by_id(order_id = order_id) if not order: logger.error(f"订单不存在: {order_id}") return False, "订单不存在" # 判断商品类型 # 激活订阅 success = activate_user_subscription( user_id=order["user_id"], museum_subscription_id=order["museum_subscription_id"], order_id=order_id ) if success: # 更新订单状态为已激活 update_order(order_id, {"status": "activated"}) access_token = await wechat_server_token_manager.get_token() if order.get("museum_subscription_id",None) and True: # 通过获取museum_subscription_id 来判断是否为订阅 # 虚拟商品(订阅)处理流程 asyncio.create_task(deliver_virtual_goods(order)) # 创建独立任务 return True, "OK" async def deliver_virtual_goods(order: dict) -> bool: """调用微信API发货虚拟商品""" try: # 获取access_token # 延时3秒,这样避免发货调用API时,获取支付订单失败 await asyncio.sleep(3) # 异步等待2秒 access_token = await wechat_server_token_manager.get_token() # 获取组合查询后的订单信息 order_combined_info = get_order_by_id(order_id= order["order_id"], combined=True) item_desc = f"{order_combined_info.get('museum_name','')}-{order_combined_info.get('template_name','')}" # 构造发货数据 delivery_data = { "order_key": { "order_number_type": 1, # 使用商户订单号 "mchid":WX_MCH_ID , # 商户号 "out_trade_no":order_combined_info["order_id"] #"transaction_id":order_combined_info["transaction_id"] }, "logistics_type": 3, # 虚拟商品标识 "delivery_mode": 1, # 统一发货 "shipping_list": [{ "item_desc": item_desc }], "upload_time": datetime.now().astimezone().isoformat(), # 带时区的时间 "payer": {"openid": order_combined_info["openid"]} } # 调用微信API async with httpx.AsyncClient() as client: url = f"https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token={access_token}" resp = await client.post(url, json=delivery_data, timeout=10) resp.raise_for_status() result = resp.json() if result.get("errcode") != 0: logger.error(f"发货API错误: {result.get('errmsg')}") return False logger.info(f"虚拟商品发货成功: {order_combined_info.get('order_id')}") return True except httpx.RequestError as e: logger.error(f"发货请求失败: {str(e)}") except Exception as e: logger.exception(f"发货处理异常: {str(e)}") return False async def generate_wx_prepay_params_v3(order_id: str, total_fee: int, openid: str, body: str): """微信支付v3统一下单""" if MERCHANT_PRIVATE_KEY is None: raise Exception("商户私钥未初始化,无法进行签名") try: # 确保序列号格式正确 cert_serial_no = WX_MCH_CERT_SERIAL_NO if not cert_serial_no: raise Exception("商户证书序列号格式错误") # 1. 构造请求参数 nonce_str = generate_nonce_str(32) params = { "appid": WX_APPID, "mchid": WX_MCH_ID, "description": body[:128], "out_trade_no": order_id, "notify_url": WX_PAY_NOTIFY_URL, "amount": { "total": total_fee, # 单位:分 "currency": "CNY" }, "payer": { "openid": openid }, "scene_info": { "payer_client_ip": "1.13.185.116", "device_id": "013467007045764", "store_info": { "id": "0005", "name": "深圳博物馆", "area_code": "440305", "address": "广东省深圳市南山区科技中一道10000号" } }, } # 2. 生成签名 timestamp = str(int(time.time())) # 确保是10位整数 method = "POST" url_path = "/v3/pay/transactions/jsapi" body_str = json.dumps(params, separators=(',', ':')) # 无空格JSON # 记录请求参数 logging.info(f"V3统一下单参数: {json.dumps(params, indent=2)}") logging.info(f"V3请求体: {body_str}") signature = generate_v3_sign(method, url_path, timestamp, nonce_str, body_str) # 3. 构造认证头 auth_header = f'WECHATPAY2-SHA256-RSA2048 ' \ f'mchid="{WX_MCH_ID}",' \ f'nonce_str="{nonce_str}",' \ f'signature="{signature}",' \ f'timestamp="{timestamp}",' \ f'serial_no="{cert_serial_no}"' #logging.info(f"V3 Authorization头: {auth_header}") # 4. 发送请求 - 使用content确保body完全一致 async with httpx.AsyncClient() as client: response = await client.post( "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi", headers={ "Authorization": auth_header, "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MyServer/1.0" }, content=body_str.encode('utf-8'), # 使用手动序列化的body timeout=15 ) # 5. 处理响应 if response.status_code != 200: error_detail = response.text logging.error(f"V3接口错误: {response.status_code} {error_detail}") # 尝试解析错误信息 try: error_data = response.json() error_code = error_data.get("code", "UNKNOWN") error_msg = error_data.get("message", "未知错误") raise Exception(f"微信支付V3接口错误: [{error_code}] {error_msg}") except: raise Exception(f"微信支付V3接口错误: HTTP {response.status_code}") resp_data = response.json() logging.info(f"V3统一下单响应: {json.dumps(resp_data, indent=2)}") if "prepay_id" not in resp_data: error_code = resp_data.get("code", "UNKNOWN") error_msg = resp_data.get("message", "未知错误") logging.error(f"V3下单失败: [{error_code}] {error_msg}") raise Exception(f"微信支付V3下单失败: {error_msg}") # 6. 生成小程序支付参数 prepay_id = resp_data["prepay_id"] pay_sign = generate_v3_pay_sign(WX_APPID, timestamp, nonce_str, prepay_id) await query_order_by_out_trade_no(order_id) return { "appId": WX_APPID, "timeStamp": timestamp, "nonceStr": nonce_str, "package": f"prepay_id={prepay_id}", "signType": "RSA", "paySign": pay_sign } except httpx.TimeoutException: logging.error("微信支付V3接口请求超时") raise Exception("微信支付接口请求超时") except httpx.RequestError as req_err: logging.error(f"微信支付V3网络请求失败: {str(req_err)}") raise Exception(f"网络连接失败: {str(req_err)}") except Exception as e: logging.error(f"V3统一下单异常: {str(e)}", exc_info=True) raise def generate_v3_sign(method: str, url: str, timestamp: str, nonce: str, body: str = "") -> str: """生成V3请求签名""" # 构造签名字符串 (必须严格按照此格式) message = f"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n" # 记录签名串用于调试 logging.info(f"V3签名串: {message}") # 使用私钥签名 signature = MERCHANT_PRIVATE_KEY.sign( message.encode('utf-8'), padding.PKCS1v15(), SHA256() ) return base64.b64encode(signature).decode('utf-8') def generate_v3_pay_sign(appid: str, timestamp: str, nonce_str: str, prepay_id: str) -> str: """生成小程序支付签名(V3)""" # 构造签名字符串 (必须严格按照此格式) sign_str = f"{appid}\n{timestamp}\n{nonce_str}\nprepay_id={prepay_id}\n" # 记录签名串用于调试 logging.info(f"V3支付签名串: {sign_str}") # 使用私钥签名 signature = MERCHANT_PRIVATE_KEY.sign( sign_str.encode('utf-8'), padding.PKCS1v15(), SHA256() ) return base64.b64encode(signature).decode('utf-8') async def query_order_by_out_trade_no(out_trade_no: str): """通过商户订单号查询订单状态""" try: # 准备请求参数 nonce_str = generate_nonce_str(32) timestamp = str(int(time.time())) url_path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={WX_MCH_ID}" # 生成签名 signature = generate_v3_sign("GET", url_path, timestamp, nonce_str) # 构造认证头 auth_header = f'WECHATPAY2-SHA256-RSA2048 ' \ f'mchid="{WX_MCH_ID}",' \ f'nonce_str="{nonce_str}",' \ f'signature="{signature}",' \ f'timestamp="{timestamp}",' \ f'serial_no="{WX_MCH_CERT_SERIAL_NO}"' # 发送请求 async with httpx.AsyncClient() as client: response = await client.get( f"https://api.mch.weixin.qq.com{url_path}", headers={ "Authorization": auth_header, "Accept": "application/json", "User-Agent": "MyServer/1.0" }, timeout=10 ) # 处理响应 if response.status_code != 200: error_detail = response.text logging.error(f"订单查询失败: {response.status_code} {error_detail}") return None else: logging.info(f"订单查询成功: {response.text}") return response.json() except Exception as e: logging.error(f"订单查询异常: {str(e)}", exc_info=True) return None """ 20250929 临时增加生成1个临时的收款二维码,用于测试学校智能水表收费 """ @payment_router.post("/create_temp_qrcode_simple") async def create_temp_qrcode_simple( request: Request ): """ 生成临时收款二维码(简化版,不创建订单) 请求参数: {"amount": 金额(元), "description": "商品描述"} 返回: 二维码URL """ try: data = await request.json() amount = data.get("amount") description = data.get("description", "临时收款") if not amount or float(amount) <= 0: raise HTTPException(status_code=400, detail="金额必须大于0") # 生成临时订单ID(仅用于支付,不存储) order_id = f"SMART_WATER_{int(time.time())}{random.randint(1000, 9999)}" amount_float = float(amount) amount_in_cents = int(amount_float * 100) # 转换为分 # 生成Native支付二维码 try: qr_code_url = await generate_simple_native_qrcode( order_id=order_id, total_fee=amount_in_cents, body=description ) except Exception as error: logger.info(f"生成支付二维码失败{error}") if not qr_code_url: raise HTTPException(status_code=500, detail="生成支付二维码失败") return JSONResponse({ "code": 0, "msg": "success", "data": { "order_id": order_id, # 返回订单ID用于测试查询 "amount": amount_float, "description": description, "qr_code_url": qr_code_url, "expire_time": (datetime.now() + timedelta(hours=4)).isoformat(), "note": "此为测试二维码,支付成功后不会激活任何服务" } }) except ValueError: raise HTTPException(status_code=400, detail="金额格式错误") except Exception as e: logger.error(f"生成临时收款二维码失败: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") async def generate_simple_native_qrcode(order_id: str, total_fee: int, body: str): """生成简化的Native支付二维码""" try: nonce_str = generate_nonce_str(32) params = { "appid": WX_APPID, "mch_id": WX_MCH_ID, "nonce_str": nonce_str, "body": f"测试-{body}"[:128], # 添加测试前缀 "out_trade_no": order_id, "total_fee": str(total_fee), "spbill_create_ip": "127.0.0.1", "notify_url": WX_PAY_NOTIFY_URL, # 使用相同的回调接口 "trade_type": "NATIVE", "time_expire": (datetime.now() + timedelta(hours=4)).strftime('%Y%m%d%H%M%S') # 4小时过期 } # 生成签名 params["sign"] = generate_sign_v2(params, WX_PAY_KEY) # 转换为XML xml_data = dict_to_xml(params) try: # 调用微信统一下单接口 response = requests.post( "https://api.mch.weixin.qq.com/pay/unifiedorder", data=xml_data.encode('utf-8'), headers={"Content-Type": "application/xml"}, timeout=10 ) except Exception as error: logging.info(f"调用生成支持二维码失败:{error}") logging.info(f"res={response}") if response.status_code != 200: logger.error(f"Native支付接口错误: {response.status_code}") return None response_data = xml_to_dict(response.text) if response_data.get("return_code") != "SUCCESS": error_msg = response_data.get("return_msg", "未知错误") logger.error(f"Native支付下单失败: {error_msg}") return None if response_data.get("result_code") != "SUCCESS": error_code = response_data.get("err_code", "") error_msg = response_data.get("err_code_des", "未知错误") logger.error(f"Native支付业务失败: [{error_code}] {error_msg}") return None # 返回二维码链接 code_url = response_data.get("code_url") if code_url: logger.info(f"生成测试二维码成功: {order_id}, 金额: {total_fee}分") return code_url else: logger.error("Native支付返回缺少code_url") return None except Exception as e: logger.error(f"生成测试二维码异常: {str(e)}", exc_info=True) return None """ 需要在 database.py 中实现以下函数: get_product_by_id create_order update_order get_user_by_id create_user_product deactivate_user_products """