1092 lines
38 KiB
Python
1092 lines
38 KiB
Python
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 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
|
||
|
||
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_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
|
||
|
||
|
||
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"],
|
||
"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_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 = is_museum_free_period(museum_id)
|
||
is_subscription_valid = get_user_valid_subscription(user_id, museum_id)
|
||
can_access = False
|
||
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
|
||
}
|
||
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 = "<xml>"
|
||
for key, value in data.items():
|
||
# 确保所有值都转为字符串
|
||
str_value = str(value)
|
||
# 对非数字类型使用CDATA包裹
|
||
if not str_value.isdigit():
|
||
xml += f"<{key}><![CDATA[{str_value}]]></{key}>"
|
||
else:
|
||
xml += f"<{key}>{str_value}</{key}>"
|
||
xml += "</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"})
|
||
|
||
return True, "OK"
|
||
|
||
|
||
# 套餐激活函数
|
||
def activate_user_product(user_id: str, product_id: int, museum_id: int, order_id: str):
|
||
product = get_product_by_id(product_id)
|
||
if not product:
|
||
return
|
||
|
||
# 计算有效期
|
||
start_time = datetime.now()
|
||
end_time = calculate_expiry(start_time, product["validity_type"])
|
||
|
||
# 创建用户套餐记录
|
||
create_user_product({
|
||
"user_id": user_id,
|
||
"product_id": product_id,
|
||
"museum_id": museum_id,
|
||
"order_id": order_id,
|
||
"start_time": start_time,
|
||
"end_time": end_time,
|
||
"is_active": True
|
||
})
|
||
|
||
# 禁用同一博物馆的旧套餐
|
||
deactivate_previous_products(user_id, museum_id)
|
||
|
||
|
||
def calculate_expiry(start_time: datetime, validity_type: str) -> datetime:
|
||
if validity_type == "free":
|
||
return start_time + timedelta(days=7) # 免费套餐7天
|
||
elif validity_type == "1month":
|
||
return start_time + timedelta(days=30)
|
||
elif validity_type == "1year":
|
||
return start_time + timedelta(days=365)
|
||
else: # permanent
|
||
return datetime(2999, 12, 31)
|
||
|
||
|
||
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
|
||
"""
|
||
需要在 database.py 中实现以下函数:
|
||
|
||
get_product_by_id
|
||
|
||
create_order
|
||
|
||
|
||
update_order
|
||
|
||
get_user_by_id
|
||
|
||
create_user_product
|
||
|
||
deactivate_user_products
|
||
"""
|