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 = [""] + for key, value in data.items(): + xml.append(f"<{key}>") + xml.append("") + 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 # ======================================== diff --git a/src/components/Subscription/SubscriptionContentNew.tsx b/src/components/Subscription/SubscriptionContentNew.tsx index e0594093..855379c8 100644 --- a/src/components/Subscription/SubscriptionContentNew.tsx +++ b/src/components/Subscription/SubscriptionContentNew.tsx @@ -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({ diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js b/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js index b1138ab4..4431766d 100644 --- a/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js +++ b/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js @@ -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 = ({ }} >
- {/* 标题 + 时间 */} + {/* 标题 */}
- -
- - {event.title} - - {/* 事件时间 */} - {(event.created_at || event.event_time || event.publish_time) && ( -
- - - {formatEventTime(event.created_at || event.event_time || event.publish_time)} - -
- )} -
+ + + {event.title} +
相关度 {event.relevance_score || 0}