refactor: 优化后端支付与微信支付

- 优化payment/config.py、wechat_pay.py
- 优化routers/payment.py、plans.py、quota.py、websocket.py
- 更新main.py、.env.production

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-11 16:06:15 +08:00
parent 240a184da8
commit 44b405d647
8 changed files with 274 additions and 38 deletions

View File

@@ -48,6 +48,15 @@ TENCENT_SMS_TEMPLATE_ID=2592163
# 并根据实际模板参数数量设置此值
TENCENT_SMS_TEMPLATE_PARAM_COUNT=1
# =============================================================================
# ASR Provider 选择
# =============================================================================
# ASR Provider: whisper默认本地 faster-whisper| tencent腾讯云一句话识别
ASR_PROVIDER=whisper
# =============================================================================
# Whisper ASR 配置ASR_PROVIDER=whisper 时使用)
# =============================================================================
# CPU 环境(推荐 small + int8
ASR_MODEL_SIZE=small
ASR_DEVICE=cpu
@@ -58,6 +67,14 @@ ASR_COMPUTE_TYPE=int8
# ASR_DEVICE=cuda
# ASR_COMPUTE_TYPE=float16
# =============================================================================
# 腾讯云 ASR 配置ASR_PROVIDER=tencent 时使用)
# =============================================================================
# 腾讯云 API 密钥(与短信服务共用,或单独配置语音服务专用密钥)
TENCENT_SECRET_ID=
TENCENT_SECRET_KEY=
# TENCENT_ASR_APP_ID=
WECHAT_PAY_APP_ID=wx1df508452e06cfb8
WECHAT_PAY_MCH_ID=1662979099
WECHAT_PAY_API_V3_KEY=xjvGSJLGJAJfjgskfjslafjsajsdjals

View File

@@ -109,7 +109,7 @@ async def startup_event():
# 检查并预加载 ASR 模型(在后台线程执行,避免阻塞启动)
try:
from services.asr_service import asr_service
from services import asr_service
asr_ready = await asyncio.to_thread(asr_service.ensure_ready)
if asr_ready:
logger.info("ASR 模型已就绪(本地 Whisper")
@@ -118,6 +118,18 @@ async def startup_event():
except Exception as e:
logger.warning(f"ASR 初始化检查失败: {e}")
# 预初始化微信支付客户端(在后台线程执行,避免首次下单时 _ensure_client 占满 25s 导致超时)
try:
def _init_wechat_pay_client():
from routers.payment import get_payment_service
svc = get_payment_service()
if svc.is_method_available("wechat"):
_ = svc.wechat_client # 触发 _ensure_client()
await asyncio.to_thread(_init_wechat_pay_client)
logger.info("微信支付客户端已预初始化")
except Exception as e:
logger.warning(f"微信支付预初始化失败(首次下单时再初始化): {e}")
@app.on_event("shutdown")
async def shutdown_event():

View File

@@ -20,6 +20,10 @@ class WeChatPayConfig:
private_key: str = "" # 商户 API 私钥内容PEM 字符串,可放环境变量,与 private_key_path 二选一)
cert_serial_no: str = "" # 商户证书序列号
notify_url: str = "" # 支付结果回调地址
# 平台公钥模式(二选一):当环境无法访问 api.mch.weixin.qq.com 时使用,无需拉取平台证书
platform_public_key: str = "" # 微信支付平台公钥 PEM 内容(与 platform_public_key_path 二选一)
platform_public_key_path: str = "" # 平台公钥文件路径
platform_public_key_id: str = "" # 微信支付平台公钥 ID与 platform_public_key 同时配置)
@property
def is_configured(self) -> bool:
@@ -29,6 +33,12 @@ class WeChatPayConfig:
has_key, self.cert_serial_no, self.notify_url
])
@property
def use_platform_public_key(self) -> bool:
"""是否使用平台公钥模式(无需访问微信拉取证书)"""
has_pub = bool(self.platform_public_key.strip()) or bool(self.platform_public_key_path.strip())
return has_pub and bool(self.platform_public_key_id.strip())
@dataclass
class AlipayConfig:
@@ -58,19 +68,28 @@ class PaymentConfig:
@classmethod
def from_env(cls) -> "PaymentConfig":
"""从环境变量加载配置"""
# 微信私钥:优先使用 WECHAT_PAY_PRIVATE_KEYPEM 内容),否则用 WECHAT_PAY_PRIVATE_KEY_PATH文件路径
# 微信私钥:优先使用 WECHAT_PAY_PRIVATE_KEY_PATH文件避免 .env 中长 PEM 的转义问题;否则用 WECHAT_PAY_PRIVATE_KEY
wechat_private_key_path = os.getenv("WECHAT_PAY_PRIVATE_KEY_PATH", "").strip()
wechat_private_key = os.getenv("WECHAT_PAY_PRIVATE_KEY", "").strip()
if wechat_private_key and "\\n" in wechat_private_key:
# 去除可能被解析进来的引号与 BOM
if wechat_private_key:
wechat_private_key = wechat_private_key.strip('"').strip("'").lstrip("\ufeff")
wechat_private_key = wechat_private_key.replace("\\n", "\n")
wechat_platform_pub = os.getenv("WECHAT_PAY_PLATFORM_PUBLIC_KEY", "").strip()
if wechat_platform_pub and "\\n" in wechat_platform_pub:
wechat_platform_pub = wechat_platform_pub.replace("\\n", "\n")
config = cls(
wechat=WeChatPayConfig(
app_id=os.getenv("WECHAT_PAY_APP_ID", ""),
mch_id=os.getenv("WECHAT_PAY_MCH_ID", ""),
api_v3_key=os.getenv("WECHAT_PAY_API_V3_KEY", ""),
private_key_path=os.getenv("WECHAT_PAY_PRIVATE_KEY_PATH", ""),
private_key=wechat_private_key,
private_key_path=wechat_private_key_path,
private_key=wechat_private_key if not wechat_private_key_path else "", # 有路径时不再用 env 内容
cert_serial_no=os.getenv("WECHAT_PAY_CERT_SERIAL_NO", ""),
notify_url=os.getenv("WECHAT_PAY_NOTIFY_URL", ""),
platform_public_key=wechat_platform_pub,
platform_public_key_path=os.getenv("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH", "").strip(),
platform_public_key_id=os.getenv("WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID", "").strip(),
),
alipay=AlipayConfig(
app_id=os.getenv("ALIPAY_APP_ID", ""),
@@ -84,7 +103,9 @@ class PaymentConfig:
# 日志输出配置状态
if config.wechat.is_configured:
logger.info(f"微信支付配置已加载: APP_ID={config.wechat.app_id}, MCH_ID={config.wechat.mch_id}")
mode = "平台公钥模式" if config.wechat.use_platform_public_key else "平台证书模式"
key_src = "WECHAT_PAY_PRIVATE_KEY(环境变量)" if config.wechat.private_key.strip() else "WECHAT_PAY_PRIVATE_KEY_PATH(文件)"
logger.info(f"微信支付配置已加载: APP_ID={config.wechat.app_id}, MCH_ID={config.wechat.mch_id}, 模式={mode}, 商户私钥={key_src}")
else:
logger.warning("微信支付配置不完整,微信支付将不可用")

View File

@@ -8,6 +8,15 @@ import os
import time
from typing import Optional, Dict, Any
# #region agent log
def _debug_log(location: str, message: str, data: dict, hypothesis_id: str):
try:
with open("/Users/zuckxu/PycharmProjects/life-echo/.cursor/debug.log", "a", encoding="utf-8") as f:
f.write(json.dumps({"id": f"log_{int(time.time()*1000)}", "timestamp": int(time.time() * 1000), "location": location, "message": message, "data": data, "hypothesisId": hypothesis_id}, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
from .config import WeChatPayConfig
from .schemas import PaymentResult, NotifyResult, PaymentStatus
from .exceptions import PaymentConfigError, PaymentCreateError, PaymentNotifyError, PaymentQueryError
@@ -17,18 +26,40 @@ logger = logging.getLogger(__name__)
def _normalize_pem_key(key: str) -> str:
"""
规范化 PEM 私钥字符串,便于 wechatpayv3 正确解析。
规范化 PEM 私钥/公钥字符串,便于 wechatpayv3 正确解析。
- 去除 BOM、首尾引号
- 环境变量中常将换行写成字面量 \\n需转为真实换行
- 统一为 \\n 换行,去除 \\r
"""
if not key or not key.strip():
return key
key = key.strip()
key = key.strip().lstrip("\ufeff").strip('"').strip("'")
key = key.replace("\\n", "\n")
key = key.replace("\r\n", "\n").replace("\r", "\n")
return key
def _resolve_key_path(key_path: str) -> str:
"""解析私钥/公钥文件路径:相对路径时优先相对于 api 目录,避免受当前工作目录影响。"""
if not key_path or not key_path.strip():
return key_path
key_path = key_path.strip()
if os.path.isabs(key_path):
return key_path
# 相对路径:先相对于当前工作目录,若文件不存在则相对于 api 目录(本包所在目录的上一级)
abs_cwd = os.path.abspath(key_path)
if os.path.isfile(abs_cwd):
return abs_cwd
try:
api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
abs_api = os.path.normpath(os.path.join(api_dir, key_path))
if os.path.isfile(abs_api):
return abs_api
except Exception:
pass
return abs_cwd
class WeChatPayClient:
"""微信支付客户端"""
@@ -36,6 +67,10 @@ class WeChatPayClient:
self._config = config
self._client = None
def ensure_client(self):
"""供外部在后台线程中提前触发初始化,避免首次下单时在预支付超时窗口内做耗时 init。"""
self._ensure_client()
def _ensure_client(self):
"""确保微信支付客户端已初始化"""
if not self._config.is_configured:
@@ -43,20 +78,20 @@ class WeChatPayClient:
if self._client is None:
try:
# #region agent log
_debug_log("payment/wechat_pay.py:_ensure_client", "wechat_ensure_client_enter", {}, "H_init")
# #endregion
from wechatpayv3 import WeChatPay, WeChatPayType
# 商户私钥:优先用配置中的 PEM 字符串,否则从文件读取
if self._config.private_key and self._config.private_key.strip():
private_key = self._config.private_key.strip()
else:
key_path = self._config.private_key_path.strip()
if not key_path:
raise PaymentConfigError("未配置微信支付商户私钥:请设置 WECHAT_PAY_PRIVATE_KEY 或 WECHAT_PAY_PRIVATE_KEY_PATH")
# 若为相对路径,相对于当前工作目录解析(建议使用绝对路径或与 main.py 同级的路径)
if not os.path.isabs(key_path):
key_path = os.path.abspath(key_path)
# 商户私钥:优先用 WECHAT_PAY_PRIVATE_KEY_PATH 从文件读取(推荐),否则用 WECHAT_PAY_PRIVATE_KEY
if self._config.private_key_path and self._config.private_key_path.strip():
key_path = _resolve_key_path(self._config.private_key_path)
with open(key_path, "r", encoding="utf-8") as f:
private_key = f.read()
elif self._config.private_key and self._config.private_key.strip():
private_key = self._config.private_key.strip()
else:
raise PaymentConfigError("未配置微信支付商户私钥:请设置 WECHAT_PAY_PRIVATE_KEY_PATH推荐或 WECHAT_PAY_PRIVATE_KEY")
# 统一 PEM 格式:换行符规范化、字面量 \n 转真实换行(环境变量或部分编辑器会写成 \n
private_key = _normalize_pem_key(private_key)
@@ -65,8 +100,12 @@ class WeChatPayClient:
"微信支付商户私钥格式错误:需为 PEM 格式(以 -----BEGIN PRIVATE KEY----- 开头)。"
"若使用 WECHAT_PAY_PRIVATE_KEY_PATH请确认文件内容正确若用 WECHAT_PAY_PRIVATE_KEY请确认环境变量为完整 PEM。"
)
# #region agent log
_debug_log("payment/wechat_pay.py:_ensure_client", "wechat_ensure_client_key_done", {}, "H_init")
# #endregion
self._client = WeChatPay(
# 平台公钥模式:无需访问 api.mch.weixin.qq.com 拉取证书,适合无法直连微信的环境
kwargs = dict(
wechatpay_type=WeChatPayType.APP,
mchid=self._config.mch_id,
private_key=private_key,
@@ -75,14 +114,42 @@ class WeChatPayClient:
apiv3_key=self._config.api_v3_key,
notify_url=self._config.notify_url,
)
if self._config.use_platform_public_key:
if self._config.platform_public_key_path and self._config.platform_public_key_path.strip():
key_path = _resolve_key_path(self._config.platform_public_key_path)
with open(key_path, "r", encoding="utf-8") as f:
platform_pub_key = _normalize_pem_key(f.read())
elif self._config.platform_public_key and self._config.platform_public_key.strip():
platform_pub_key = _normalize_pem_key(self._config.platform_public_key.strip())
else:
raise PaymentConfigError("平台公钥模式需设置 WECHAT_PAY_PLATFORM_PUBLIC_KEY 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH")
if not platform_pub_key or "-----BEGIN" not in platform_pub_key:
raise PaymentConfigError("微信支付平台公钥格式错误,需为 PEM 格式")
kwargs["public_key"] = platform_pub_key
kwargs["public_key_id"] = self._config.platform_public_key_id.strip()
else:
kwargs["timeout"] = (10, 25) # 证书模式:拉取平台证书时的超时
self._client = WeChatPay(**kwargs)
# #region agent log
_debug_log("payment/wechat_pay.py:_ensure_client", "wechat_ensure_client_sdk_done", {}, "H_init")
# #endregion
logger.info("微信支付客户端初始化成功")
except FileNotFoundError:
key_path = _resolve_key_path(self._config.private_key_path) if self._config.private_key_path else self._config.private_key_path
raise PaymentConfigError(
f"微信支付商户私钥文件不存在: {self._config.private_key_path}当前工作目录: {os.getcwd()}"
f"微信支付商户私钥文件不存在: {key_path}(已尝试相对路径与 api 目录,当前工作目录: {os.getcwd()}"
)
except PaymentConfigError:
raise
except Exception as e:
msg = str(e).strip()
if "No wechatpay platform certificate" in msg or "platform certificate" in msg.lower():
raise PaymentConfigError(
"无法获取微信支付平台证书(当前环境可能无法访问 api.mch.weixin.qq.com"
"请改用平台公钥模式在商户平台「API安全」中获取微信支付公钥与公钥ID并设置环境变量 "
"WECHAT_PAY_PLATFORM_PUBLIC_KEY或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID。"
)
raise PaymentConfigError(
f"微信支付客户端初始化失败: {e}"
"若使用文件路径,请确认文件为 PEM 格式且路径正确;或改用 WECHAT_PAY_PRIVATE_KEY 直接配置私钥内容。"
@@ -105,15 +172,26 @@ class WeChatPayClient:
Returns:
PaymentResult 包含调起支付所需的参数
"""
# #region agent log
_debug_log("payment/wechat_pay.py:create_app_order", "wechat_create_app_order_enter", {"out_trade_no": out_trade_no}, "H3")
# #endregion
self._ensure_client()
# #region agent log
_debug_log("payment/wechat_pay.py:create_app_order", "wechat_client_ready", {"out_trade_no": out_trade_no}, "H3")
# #endregion
try:
# #region agent log
_debug_log("payment/wechat_pay.py:create_app_order", "wechat_pay_request_start", {"out_trade_no": out_trade_no}, "H1,H4")
# #endregion
code, message = self._client.pay(
description=description,
out_trade_no=out_trade_no,
amount={"total": total_amount, "currency": "CNY"},
pay_type=self._get_pay_type(),
)
# #region agent log
_debug_log("payment/wechat_pay.py:create_app_order", "wechat_pay_request_done", {"out_trade_no": out_trade_no, "code": code}, "H1,H4")
# #endregion
result = json.loads(message)

View File

@@ -3,11 +3,23 @@
处理订单创建、支付回调、订单状态查询
"""
import asyncio
import json
import time
import traceback
import uuid
import logging
from datetime import timedelta
from typing import List
# #region agent log
def _debug_log(location: str, message: str, data: dict, hypothesis_id: str):
try:
with open("/Users/zuckxu/PycharmProjects/life-echo/.cursor/debug.log", "a", encoding="utf-8") as f:
f.write(json.dumps({"id": f"log_{int(time.time()*1000)}", "timestamp": int(time.time() * 1000), "location": location, "message": message, "data": data, "hypothesisId": hypothesis_id}, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
@@ -24,7 +36,7 @@ from payment.schemas import (
OrderListResponse,
)
from payment.exceptions import PaymentError
from routers.plans import get_plan_by_type, AVAILABLE_PLANS
from routers.plans import get_plan_by_type, get_plans_for_api
logger = logging.getLogger(__name__)
@@ -51,6 +63,7 @@ SUBSCRIPTION_DURATION_DAYS = {
"pro": 365, # Pro 版一年
"pro_plus": 365, # Pro+ 版一年
"premium": 365, # 兼容旧高级版
"test": 365, # 一分钱测试版(仅开发环境)
}
@@ -74,9 +87,9 @@ async def create_order(
- plan_id: 套餐 ID如 premium
- payment_method: 支付方式wechat / alipay
"""
# 验证套餐
# 验证套餐(含 ENABLE_TEST_PLAN 时的「一分钱测试版」)
plan = None
for p in AVAILABLE_PLANS:
for p in get_plans_for_api():
if p.id == request.plan_id:
plan = p
break
@@ -127,20 +140,79 @@ async def create_order(
await db.flush()
# 调用支付服务创建预支付订单(同步的微信/支付宝 SDK 会发 HTTP放到线程池避免阻塞事件循环导致超时
# 预支付超时时间(秒),便于区分「后端超时」与「客户端超时」
PREPAY_TIMEOUT_SEC = 25
# 微信客户端首次初始化可能较慢(读密钥、构造 SDK、或 SDK 内部建连),多 worker 时 startup 预初始化可能未在本进程执行,故在下单前先确保本进程内已初始化
WECHAT_INIT_TIMEOUT_SEC = 35
# #region agent log
_debug_log("routers/payment.py:create_order", "create_order_prepay_start", {"order_no": order_no, "payment_method": request.payment_method, "plan_id": request.plan_id}, "H2,H3,H5")
# #endregion
if request.payment_method == "wechat":
try:
await asyncio.wait_for(
asyncio.to_thread(payment_service.wechat_client.ensure_client),
timeout=WECHAT_INIT_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
order.status = "failed"
await db.flush()
logger.error("微信支付客户端初始化超时(%s 秒), order_no=%s", WECHAT_INIT_TIMEOUT_SEC, order_no)
raise HTTPException(status_code=504, detail="微信支付初始化超时,请稍后重试。")
except Exception as e:
# 未配置或初始化失败(如私钥错误),直接返回 503
order.status = "failed"
await db.flush()
logger.exception("微信支付客户端初始化失败: %s", e)
raise HTTPException(status_code=503, detail=f"微信支付暂不可用: {e!s}")
try:
payment_result = await asyncio.to_thread(
payment_service.create_payment,
request.payment_method,
order_no,
amount_fen,
f"往事拾遗 - {plan.display_name}",
payment_result = await asyncio.wait_for(
asyncio.to_thread(
payment_service.create_payment,
request.payment_method,
order_no,
amount_fen,
f"往事拾遗 - {plan.display_name}",
),
timeout=PREPAY_TIMEOUT_SEC,
)
except PaymentError as e:
# 支付下单失败,更新订单状态
except asyncio.TimeoutError:
# #region agent log
_debug_log("routers/payment.py:create_order", "create_order_prepay_timeout", {"order_no": order_no, "payment_method": request.payment_method}, "H1,H2,H3,H4")
# #endregion
order.status = "failed"
await db.flush()
logger.error(f"创建支付订单失败: {e.message}")
logger.error(
"创建支付订单超时: 预支付请求在 %s 秒内未返回, plan_id=%s, payment_method=%s, order_no=%s",
PREPAY_TIMEOUT_SEC,
request.plan_id,
request.payment_method,
order_no,
)
raise HTTPException(
status_code=504,
detail="创建预支付超时,请检查网络或稍后重试。若为微信支付,请确认商户配置与网络可达微信服务器。",
)
except PaymentError as e:
order.status = "failed"
await db.flush()
logger.error("创建支付订单失败(PaymentError): %s", e.message)
raise HTTPException(status_code=500, detail=f"创建支付订单失败: {e.message}")
except Exception as e:
order.status = "failed"
await db.flush()
full_tb = traceback.format_exc()
logger.exception(
"创建支付订单异常: %s, plan_id=%s, payment_method=%s, order_no=%s\n%s",
e,
request.plan_id,
request.payment_method,
order_no,
full_tb,
)
# 返回详尽错误信息便于客户端 debug类型 + 消息)
detail_msg = f"创建支付订单异常: {type(e).__name__}: {e!s}"
raise HTTPException(status_code=500, detail=detail_msg)
logger.info(f"订单创建成功: {order_no}, 方式: {request.payment_method}, 金额: {amount_fen}")

View File

@@ -1,6 +1,7 @@
"""
订阅计划相关 API 路由
"""
import os
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import List, Optional
@@ -94,11 +95,40 @@ AVAILABLE_PLANS = [
)
]
# 一分钱测试套餐:仅当 ENABLE_TEST_PLAN=1 时开放,用于开发环境反复测试支付
ENABLE_TEST_PLAN = os.getenv("ENABLE_TEST_PLAN", "").lower() in ("1", "true", "yes")
TEST_PLAN = PlanResponse(
id="test",
name="test",
display_name="一分钱测试版",
price=0.01,
currency="CNY",
features=[
"无限对话",
"无限章节整理",
"仅用于开发环境测试支付"
],
max_conversations=None,
max_chapters=None,
max_words=None,
is_popular=False,
)
def get_plans_for_api() -> List[PlanResponse]:
"""返回对外暴露的套餐列表(含测试套餐当且仅当 ENABLE_TEST_PLAN 开启)。"""
if ENABLE_TEST_PLAN:
return AVAILABLE_PLANS + [TEST_PLAN]
return list(AVAILABLE_PLANS)
def get_plan_by_type(subscription_type: str) -> Optional[PlanResponse]:
"""根据订阅类型获取计划信息。旧字段 premium 按 pro 展示。"""
"""根据订阅类型获取计划信息。旧字段 premium 按 pro 展示test 仅当 ENABLE_TEST_PLAN 时有效"""
if subscription_type == "premium":
subscription_type = "pro"
if subscription_type == "test":
return TEST_PLAN if ENABLE_TEST_PLAN else AVAILABLE_PLANS[0]
for plan in AVAILABLE_PLANS:
if plan.id == subscription_type:
return plan
@@ -108,9 +138,9 @@ def get_plan_by_type(subscription_type: str) -> Optional[PlanResponse]:
@router.get("", response_model=List[PlanResponse])
async def get_plans():
"""
获取所有可用的订阅计划
获取所有可用的订阅计划(开发环境 ENABLE_TEST_PLAN=1 时包含「一分钱测试版」)。
"""
return AVAILABLE_PLANS
return get_plans_for_api()
@router.get("/current", response_model=CurrentPlanResponse)

View File

@@ -53,7 +53,13 @@ PLAN_QUOTAS = {
"max_conversations": None,
"max_chapters": None,
"max_words": None
}
},
# 一分钱测试版(仅开发环境 ENABLE_TEST_PLAN=1无限对话、无限章节
"test": {
"max_conversations": None,
"max_chapters": None,
"max_words": None
},
}

View File

@@ -19,7 +19,7 @@ from database.models import Conversation, Segment
from database.models import User as UserModel
from services.auth_service import verify_token
from services.memoir_state_service import get_or_create_state
from services.asr_service import asr_service
from services import asr_service
from fastapi import HTTPException, status
logger = logging.getLogger(__name__)