更新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 base64
import csv import csv
import hashlib
import io import io
import threading import threading
import time import time
import urllib import urllib
from urllib.parse import quote
import uuid import uuid
from functools import wraps from functools import wraps
import qrcode import qrcode
@@ -3646,6 +3648,435 @@ def _parse_xml_callback(xml_data):
return None 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 # 支付宝支付相关API
# ======================================== # ========================================

View File

@@ -270,6 +270,130 @@ export default function SubscriptionContentNew() {
checkAlipayReturn(); checkAlipayReturn();
}, [toast]); }, [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 () => { const fetchSubscriptionPlans = async () => {
try { try {
logger.debug('SubscriptionContentNew', '正在获取订阅套餐'); 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'; const paymentMethodName = paymentMethod === 'alipay' ? 'alipay' : 'wechat_pay';
subscriptionEvents.trackPaymentInitiated({ subscriptionEvents.trackPaymentInitiated({

View File

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