更新ios

This commit is contained in:
2026-01-23 17:04:24 +08:00
parent 4520dc206a
commit 5996713c8e
3 changed files with 621 additions and 40 deletions

431
app.py
View File

@@ -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 = ["<xml>"]
for key, value in data.items():
xml.append(f"<{key}><![CDATA[{value}]]></{key}>")
xml.append("</xml>")
return "".join(xml)
def _xml_to_dict(xml_data):
"""将 XML 转换为字典"""
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(xml_data)
return {child.tag: child.text for child in root}
except Exception as e:
print(f"XML 解析失败: {e}")
return {}
# ========================================
# 支付宝支付相关API
# ========================================

View File

@@ -270,6 +270,130 @@ export default function SubscriptionContentNew() {
checkAlipayReturn();
}, [toast]);
// 检查是否从微信 H5 支付授权返回
useEffect(() => {
const handleWechatH5PayReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const wechatH5Pay = urlParams.get('wechat_h5_pay');
const token = urlParams.get('token');
const wechatPayError = urlParams.get('wechat_pay_error');
// 处理授权错误
if (wechatPayError) {
toast({
title: '微信授权失败',
description: wechatPayError === 'missing_code' ? '授权被取消' : wechatPayError,
status: 'error',
duration: 5000,
isClosable: true,
});
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
// 处理授权成功,发起 JSAPI 支付
if (wechatH5Pay === 'ready' && token) {
console.log('[微信H5支付] 授权成功,准备发起支付');
toast({
title: '授权成功',
description: '正在发起微信支付...',
status: 'info',
duration: 2000,
isClosable: true,
});
try {
// 调用 JSAPI 创建订单接口
const response = await fetch(`${getApiBase()}/api/payment/wechat/jsapi/create-order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || '创建订单失败');
}
console.log('[微信H5支付] 订单创建成功,调用 WeixinJSBridge');
// 调用 WeixinJSBridge 发起支付
const paymentParams = data.data.payment_params;
// 等待 WeixinJSBridge 就绪
const invokePay = () => {
(window as any).WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: paymentParams.appId,
timeStamp: paymentParams.timeStamp,
nonceStr: paymentParams.nonceStr,
package: paymentParams.package,
signType: paymentParams.signType,
paySign: paymentParams.paySign,
},
(res: any) => {
// 清除 URL 参数
window.history.replaceState({}, document.title, window.location.pathname);
if (res.err_msg === 'get_brand_wcpay_request:ok') {
toast({
title: '支付成功!',
description: '您的订阅已激活',
status: 'success',
duration: 5000,
isClosable: true,
});
setTimeout(() => window.location.reload(), 2000);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
toast({
title: '支付已取消',
description: '您可以稍后重新支付',
status: 'warning',
duration: 3000,
isClosable: true,
});
} else {
toast({
title: '支付失败',
description: res.err_msg || '请稍后重试',
status: 'error',
duration: 3000,
isClosable: true,
});
}
}
);
};
if (typeof (window as any).WeixinJSBridge !== 'undefined') {
invokePay();
} else {
document.addEventListener('WeixinJSBridgeReady', invokePay, false);
}
} catch (error: any) {
console.error('[微信H5支付] 支付失败:', error);
toast({
title: '支付失败',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true,
});
window.history.replaceState({}, document.title, window.location.pathname);
}
}
};
handleWechatH5PayReturn();
}, [toast]);
const fetchSubscriptionPlans = async () => {
try {
logger.debug('SubscriptionContentNew', '正在获取订阅套餐');
@@ -484,6 +608,54 @@ export default function SubscriptionContentNew() {
}
}
// 检测是否在微信内置浏览器中
const isWechatBrowser = /MicroMessenger/i.test(navigator.userAgent);
// 微信浏览器内选择微信支付:使用 JSAPI 支付
if (paymentMethod === 'wechat' && isWechatBrowser) {
console.log('[微信H5支付] 检测到微信浏览器,跳转到 OAuth 授权');
subscriptionEvents.trackPaymentInitiated({
planName: selectedPlan.name,
paymentMethod: 'wechat_jsapi',
amount: price,
billingCycle: selectedCycle,
});
// 获取微信 OAuth 授权 URL
const authUrlResponse = await fetch(
`${getApiBase()}/api/payment/wechat/h5/auth-url?` +
`plan_name=${encodeURIComponent(selectedPlan.name)}` +
`&billing_cycle=${encodeURIComponent(selectedCycle)}` +
`&promo_code=${encodeURIComponent(promoCodeApplied ? promoCode : '')}`,
{ credentials: 'include' }
);
const authUrlData = await authUrlResponse.json();
if (!authUrlData.success) {
throw new Error(authUrlData.error || '获取授权链接失败');
}
toast({
title: '正在跳转微信授权',
description: '请在弹出的页面中确认授权',
status: 'info',
duration: 2000,
isClosable: true,
});
// 关闭支付弹窗
onClose();
// 跳转到微信授权页面
setTimeout(() => {
window.location.href = authUrlData.auth_url;
}, 500);
return;
}
const paymentMethodName = paymentMethod === 'alipay' ? 'alipay' : 'wechat_pay';
subscriptionEvents.trackPaymentInitiated({

View File

@@ -2,19 +2,9 @@
// 使用 Ant Design Modal 保持与现有代码风格一致
import React from "react";
import { Modal as AntModal, Tag, ConfigProvider, theme } from "antd";
import { FileText, Clock } from "lucide-react";
import { FileText } from "lucide-react";
import { GLASS_BLUR } from "@/constants/glassConfig";
import dayjs from "dayjs";
/**
* 格式化事件时间
*/
const formatEventTime = (time) => {
if (!time) return null;
const d = dayjs(time);
if (!d.isValid()) return null;
return d.format("MM-DD HH:mm");
};
import { getEventDetailUrl } from "@/utils/idEncoder";
/**
* 获取相关度颜色
@@ -112,7 +102,7 @@ const RelatedEventsModal = ({
transition: "all 0.2s",
}}
onClick={() => {
window.open(`/community?event_id=${event.event_id}`, "_blank");
window.open(getEventDetailUrl(event.event_id), "_blank");
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(40,40,70,0.9)";
@@ -126,34 +116,23 @@ const RelatedEventsModal = ({
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 标题 + 时间 */}
{/* 标题 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ display: "flex", gap: "8px", flex: 1 }}>
<FileText size={16} color="#60A5FA" style={{ flexShrink: 0, marginTop: "2px" }} />
<div style={{ flex: 1 }}>
<span
style={{
fontSize: "14px",
fontWeight: "600",
color: "#E0E0E0",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{event.title}
</span>
{/* 事件时间 */}
{(event.created_at || event.event_time || event.publish_time) && (
<div style={{ display: "flex", alignItems: "center", gap: "4px", marginTop: "4px" }}>
<Clock size={12} color="rgba(255,255,255,0.4)" />
<span style={{ fontSize: "11px", color: "rgba(255,255,255,0.4)" }}>
{formatEventTime(event.created_at || event.event_time || event.publish_time)}
</span>
</div>
)}
</div>
<FileText size={16} color="#60A5FA" style={{ flexShrink: 0 }} />
<span
style={{
fontSize: "14px",
fontWeight: "600",
color: "#E0E0E0",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{event.title}
</span>
</div>
<span
style={{
@@ -163,7 +142,6 @@ const RelatedEventsModal = ({
padding: "2px 8px",
borderRadius: "6px",
flexShrink: 0,
marginLeft: "8px",
}}
>
相关度 {event.relevance_score || 0}