diff --git a/app.py b/app.py
index 94318828..e1911c14 100755
--- a/app.py
+++ b/app.py
@@ -42,10 +42,12 @@ else:
import base64
import csv
+import hashlib
import io
import threading
import time
import urllib
+from urllib.parse import quote
import uuid
from functools import wraps
import qrcode
@@ -3646,6 +3648,435 @@ def _parse_xml_callback(xml_data):
return None
+# ========================================
+# 微信 H5 JSAPI 支付相关 API
+# ========================================
+
+@app.route('/api/payment/wechat/h5/auth-url', methods=['GET'])
+def get_wechat_h5_auth_url():
+ """
+ 获取微信 H5 网页授权 URL
+
+ 用于在微信内置浏览器中获取用户 openid,以便发起 JSAPI 支付
+
+ Query Parameters:
+ plan_name: 套餐名称
+ billing_cycle: 计费周期
+ promo_code: 优惠码(可选)
+
+ Returns:
+ { "success": true, "auth_url": "https://open.weixin.qq.com/..." }
+ """
+ try:
+ if 'user_id' not in session:
+ return jsonify({'success': False, 'error': '未登录'}), 401
+
+ plan_name = request.args.get('plan_name')
+ billing_cycle = request.args.get('billing_cycle')
+ promo_code = request.args.get('promo_code', '')
+
+ if not plan_name or not billing_cycle:
+ return jsonify({'success': False, 'error': '参数不完整'}), 400
+
+ # 构建 state 参数(包含订单信息,用于回调后恢复)
+ import base64
+ state_data = {
+ 'user_id': session['user_id'],
+ 'plan_name': plan_name,
+ 'billing_cycle': billing_cycle,
+ 'promo_code': promo_code,
+ 'timestamp': int(time.time())
+ }
+ state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
+
+ # 回调地址
+ redirect_uri = quote('https://api.valuefrontier.cn/api/payment/wechat/h5/callback')
+
+ # 构建微信授权 URL(使用 snsapi_base 静默授权,只获取 openid)
+ auth_url = (
+ f"https://open.weixin.qq.com/connect/oauth2/authorize?"
+ f"appid={WECHAT_MP_APPID}"
+ f"&redirect_uri={redirect_uri}"
+ f"&response_type=code"
+ f"&scope=snsapi_base"
+ f"&state={state}"
+ f"#wechat_redirect"
+ )
+
+ print(f"[微信H5支付] 生成授权URL: user_id={session['user_id']}, plan={plan_name}")
+
+ return jsonify({
+ 'success': True,
+ 'auth_url': auth_url
+ })
+
+ except Exception as e:
+ print(f"[微信H5支付] 生成授权URL失败: {e}")
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@app.route('/api/payment/wechat/h5/callback', methods=['GET'])
+def wechat_h5_payment_callback():
+ """
+ 微信 H5 网页授权回调
+
+ 微信授权后会携带 code 和 state 回调到此接口
+ 获取 openid 后重定向到前端支付页面
+ """
+ try:
+ code = request.args.get('code')
+ state = request.args.get('state')
+
+ if not code:
+ print("[微信H5支付] 回调缺少 code 参数")
+ return redirect('https://valuefrontier.cn/home/pages/account/subscription?wechat_pay_error=missing_code')
+
+ # 解析 state
+ try:
+ import base64
+ state_data = json.loads(base64.urlsafe_b64decode(state).decode())
+ user_id = state_data.get('user_id')
+ plan_name = state_data.get('plan_name')
+ billing_cycle = state_data.get('billing_cycle')
+ promo_code = state_data.get('promo_code', '')
+ except Exception as e:
+ print(f"[微信H5支付] 解析 state 失败: {e}")
+ return redirect('https://valuefrontier.cn/home/pages/account/subscription?wechat_pay_error=invalid_state')
+
+ # 使用 code 换取 openid
+ token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
+ params = {
+ 'appid': WECHAT_MP_APPID,
+ 'secret': WECHAT_MP_APPSECRET,
+ 'code': code,
+ 'grant_type': 'authorization_code'
+ }
+
+ response = requests.get(token_url, params=params, timeout=10)
+ result = response.json()
+
+ if 'openid' not in result:
+ print(f"[微信H5支付] 获取 openid 失败: {result}")
+ error_msg = result.get('errmsg', 'unknown_error')
+ return redirect(f'https://valuefrontier.cn/home/pages/account/subscription?wechat_pay_error={error_msg}')
+
+ openid = result['openid']
+ print(f"[微信H5支付] 获取 openid 成功: {openid[:10]}...")
+
+ # 将 openid 加密存储,避免在 URL 中暴露
+ # 使用简单的 base64 + 时间戳校验
+ openid_token_data = {
+ 'openid': openid,
+ 'user_id': user_id,
+ 'timestamp': int(time.time()),
+ 'plan_name': plan_name,
+ 'billing_cycle': billing_cycle,
+ 'promo_code': promo_code
+ }
+ openid_token = base64.urlsafe_b64encode(json.dumps(openid_token_data).encode()).decode()
+
+ # 重定向到前端支付页面
+ redirect_url = (
+ f"https://valuefrontier.cn/home/pages/account/subscription"
+ f"?wechat_h5_pay=ready"
+ f"&token={openid_token}"
+ )
+
+ print(f"[微信H5支付] 重定向到前端: {redirect_url[:80]}...")
+ return redirect(redirect_url)
+
+ except Exception as e:
+ print(f"[微信H5支付] 回调处理失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return redirect(f'https://valuefrontier.cn/home/pages/account/subscription?wechat_pay_error=callback_failed')
+
+
+@app.route('/api/payment/wechat/jsapi/create-order', methods=['POST'])
+def create_wechat_jsapi_order():
+ """
+ 创建微信 JSAPI 支付订单
+
+ 用于微信内置浏览器中的 H5 支付
+
+ Request Body:
+ {
+ "token": "xxx", // 从 OAuth 回调获取的 token(包含 openid 等信息)
+ }
+
+ Returns:
+ {
+ "success": true,
+ "data": {
+ "order_id": "xxx",
+ "order_no": "xxx",
+ "amount": 99.00,
+ "payment_params": {
+ "appId": "xxx",
+ "timeStamp": "xxx",
+ "nonceStr": "xxx",
+ "package": "prepay_id=xxx",
+ "signType": "MD5",
+ "paySign": "xxx"
+ }
+ }
+ }
+ """
+ try:
+ data = request.get_json()
+ token = data.get('token')
+
+ if not token:
+ return jsonify({'success': False, 'error': '缺少 token 参数'}), 400
+
+ # 解析 token
+ try:
+ import base64
+ token_data = json.loads(base64.urlsafe_b64decode(token).decode())
+ openid = token_data.get('openid')
+ user_id = token_data.get('user_id')
+ plan_name = token_data.get('plan_name')
+ billing_cycle = token_data.get('billing_cycle')
+ promo_code = token_data.get('promo_code', '') or None
+ timestamp = token_data.get('timestamp', 0)
+ except Exception as e:
+ print(f"[JSAPI支付] 解析 token 失败: {e}")
+ return jsonify({'success': False, 'error': 'token 无效'}), 400
+
+ # 验证 token 时效(30分钟内有效)
+ if time.time() - timestamp > 1800:
+ return jsonify({'success': False, 'error': 'token 已过期,请重新授权'}), 400
+
+ # 验证用户登录状态
+ if 'user_id' not in session or session['user_id'] != user_id:
+ return jsonify({'success': False, 'error': '用户状态异常,请重新登录'}), 401
+
+ print(f"[JSAPI支付] 创建订单: user_id={user_id}, plan={plan_name}, openid={openid[:10]}...")
+
+ # 计算价格
+ price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, promo_code)
+
+ if 'error' in price_result:
+ return jsonify({'success': False, 'error': price_result['error']}), 400
+
+ amount = price_result['final_amount']
+
+ # 检查是否为免费升级
+ if amount <= 0 and price_result.get('is_upgrade'):
+ return jsonify({
+ 'success': False,
+ 'error': '当前剩余价值可直接免费升级',
+ 'should_free_upgrade': True
+ }), 400
+
+ # 生成订单号
+ order_no = f"WXH5{int(time.time())}{user_id}{uuid.uuid4().hex[:6].upper()}"
+
+ # 创建订单记录
+ order = PaymentOrder(
+ user_id=user_id,
+ order_no=order_no,
+ plan_name=plan_name,
+ billing_cycle=billing_cycle,
+ amount=amount,
+ original_amount=price_result.get('original_price', amount),
+ discount_amount=price_result.get('discount_amount', 0),
+ status='pending',
+ payment_method='wechat_jsapi',
+ payment_source='h5'
+ )
+
+ # 关联优惠码
+ if promo_code and price_result.get('promo_applied'):
+ promo = PromoCode.query.filter_by(code=promo_code.upper()).first()
+ if promo:
+ order.promo_code_id = promo.id
+
+ db.session.add(order)
+ db.session.commit()
+
+ # 调用微信统一下单接口(JSAPI 模式)
+ jsapi_result = _create_wechat_jsapi_prepay(order_no, amount, plan_name, openid)
+
+ if not jsapi_result['success']:
+ # 标记订单失败
+ order.status = 'failed'
+ order.remark = jsapi_result.get('error', '创建预支付订单失败')
+ db.session.commit()
+ return jsonify({'success': False, 'error': jsapi_result.get('error', '创建支付订单失败')}), 500
+
+ # 更新订单的 prepay_id
+ order.prepay_id = jsapi_result.get('prepay_id')
+ db.session.commit()
+
+ print(f"[JSAPI支付] 订单创建成功: order_no={order_no}, amount={amount}")
+
+ return jsonify({
+ 'success': True,
+ 'data': {
+ 'order_id': order.id,
+ 'order_no': order_no,
+ 'amount': amount,
+ 'plan_name': plan_name,
+ 'payment_params': jsapi_result['payment_params']
+ }
+ })
+
+ except Exception as e:
+ db.session.rollback()
+ print(f"[JSAPI支付] 创建订单失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return jsonify({'success': False, 'error': f'创建订单失败: {str(e)}'}), 500
+
+
+def _create_wechat_jsapi_prepay(order_no, amount, body, openid):
+ """
+ 调用微信统一下单接口创建 JSAPI 预支付订单
+
+ Args:
+ order_no: 商户订单号
+ amount: 金额(元)
+ body: 商品描述
+ openid: 用户的 openid
+
+ Returns:
+ {
+ 'success': True/False,
+ 'prepay_id': 'xxx',
+ 'payment_params': {...} # 前端调用支付需要的参数
+ }
+ """
+ try:
+ from wechat_pay_config import WECHAT_PAY_CONFIG
+
+ # 金额转换为分
+ total_fee = int(amount * 100)
+
+ # 构建请求参数
+ params = {
+ 'appid': WECHAT_MP_APPID, # 使用服务号 AppID
+ 'mch_id': WECHAT_PAY_CONFIG['mch_id'],
+ 'nonce_str': uuid.uuid4().hex,
+ 'body': f'价值前研-{body}订阅',
+ 'out_trade_no': order_no,
+ 'total_fee': str(total_fee),
+ 'spbill_create_ip': request.remote_addr or '127.0.0.1',
+ 'notify_url': WECHAT_PAY_CONFIG['notify_url'],
+ 'trade_type': 'JSAPI',
+ 'openid': openid
+ }
+
+ # 生成签名
+ api_key = WECHAT_PAY_CONFIG['api_key']
+ sign = _generate_wechat_sign(params, api_key)
+ params['sign'] = sign
+
+ # 转换为 XML
+ xml_data = _dict_to_xml(params)
+
+ print(f"[JSAPI支付] 统一下单请求: order_no={order_no}, amount={amount}元={total_fee}分")
+
+ # 发送请求
+ response = requests.post(
+ 'https://api.mch.weixin.qq.com/pay/unifiedorder',
+ data=xml_data.encode('utf-8'),
+ headers={'Content-Type': 'text/xml'},
+ timeout=30
+ )
+
+ # 解析响应
+ result = _xml_to_dict(response.text)
+ print(f"[JSAPI支付] 统一下单响应: {result}")
+
+ if result.get('return_code') != 'SUCCESS':
+ error_msg = result.get('return_msg', '通信失败')
+ print(f"[JSAPI支付] 统一下单失败: {error_msg}")
+ return {'success': False, 'error': error_msg}
+
+ if result.get('result_code') != 'SUCCESS':
+ error_msg = result.get('err_code_des') or result.get('err_code', '业务失败')
+ print(f"[JSAPI支付] 统一下单业务失败: {error_msg}")
+ return {'success': False, 'error': error_msg}
+
+ prepay_id = result.get('prepay_id')
+ if not prepay_id:
+ return {'success': False, 'error': '未获取到 prepay_id'}
+
+ # 生成前端调用支付需要的参数
+ timestamp = str(int(time.time()))
+ nonce_str = uuid.uuid4().hex
+ package = f"prepay_id={prepay_id}"
+
+ # 签名参数(注意:这里的 appId 是大写 I)
+ sign_params = {
+ 'appId': WECHAT_MP_APPID,
+ 'timeStamp': timestamp,
+ 'nonceStr': nonce_str,
+ 'package': package,
+ 'signType': 'MD5'
+ }
+ pay_sign = _generate_wechat_sign(sign_params, api_key)
+
+ payment_params = {
+ 'appId': WECHAT_MP_APPID,
+ 'timeStamp': timestamp,
+ 'nonceStr': nonce_str,
+ 'package': package,
+ 'signType': 'MD5',
+ 'paySign': pay_sign
+ }
+
+ print(f"[JSAPI支付] 支付参数生成成功: prepay_id={prepay_id[:20]}...")
+
+ return {
+ 'success': True,
+ 'prepay_id': prepay_id,
+ 'payment_params': payment_params
+ }
+
+ except Exception as e:
+ print(f"[JSAPI支付] 统一下单异常: {e}")
+ import traceback
+ traceback.print_exc()
+ return {'success': False, 'error': str(e)}
+
+
+def _generate_wechat_sign(params, api_key):
+ """生成微信支付签名"""
+ # 过滤空值并排序
+ filtered = {k: v for k, v in params.items() if v and k != 'sign'}
+ sorted_params = sorted(filtered.items())
+
+ # 拼接字符串
+ string_a = "&".join([f"{k}={v}" for k, v in sorted_params])
+ string_sign_temp = f"{string_a}&key={api_key}"
+
+ # MD5 签名
+ sign = hashlib.md5(string_sign_temp.encode('utf-8')).hexdigest().upper()
+ return sign
+
+
+def _dict_to_xml(data):
+ """将字典转换为 XML"""
+ xml = ["