diff --git a/app.py b/app.py
index e7891c80..56807a69 100755
--- a/app.py
+++ b/app.py
@@ -333,6 +333,15 @@ WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
WECHAT_MP_APPSECRET = 'c3ec5a227ddb26ad8a1d4c55efa1cf86'
+# 微信小程序配置(H5 跳转小程序用)
+WECHAT_MINIPROGRAM_APPID = 'wx0edeaab76d4fa414'
+WECHAT_MINIPROGRAM_APPSECRET = os.environ.get('WECHAT_MINIPROGRAM_APPSECRET', '0d0c70084f05a8c1411f6b89da7e815d')
+WECHAT_MINIPROGRAM_ORIGINAL_ID = 'gh_fd2fd8dd2fb5'
+
+# Redis 缓存键前缀(微信 token)
+WECHAT_ACCESS_TOKEN_PREFIX = "wechat:access_token:"
+WECHAT_JSAPI_TICKET_PREFIX = "wechat:jsapi_ticket:"
+
# 微信回调地址
WECHAT_REDIRECT_URI = 'https://valuefrontier.cn/api/auth/wechat/callback'
@@ -4751,6 +4760,326 @@ def unbind_wechat_account():
return jsonify({'error': '解绑失败,请重试'}), 500
+# ============ H5 跳转小程序相关 API ============
+
+def get_wechat_access_token_cached(appid, appsecret):
+ """
+ 获取微信 access_token(Redis 缓存,支持多 Worker)
+
+ Args:
+ appid: 微信 AppID(公众号或小程序)
+ appsecret: 对应的 AppSecret
+
+ Returns:
+ access_token 字符串,失败返回 None
+ """
+ cache_key = f"{WECHAT_ACCESS_TOKEN_PREFIX}{appid}"
+
+ # 1. 尝试从 Redis 获取缓存
+ try:
+ cached = redis_client.get(cache_key)
+ if cached:
+ data = json.loads(cached)
+ # 提前 5 分钟刷新,避免临界问题
+ if data.get('expires_at', 0) > time.time() + 300:
+ print(f"[access_token] 使用缓存: appid={appid[:8]}...")
+ return data['token']
+ except Exception as e:
+ print(f"[access_token] Redis 读取失败: {e}")
+
+ # 2. 请求新 token
+ url = "https://api.weixin.qq.com/cgi-bin/token"
+ params = {
+ 'grant_type': 'client_credential',
+ 'appid': appid,
+ 'secret': appsecret
+ }
+
+ try:
+ response = requests.get(url, params=params, timeout=10)
+ result = response.json()
+
+ if 'access_token' in result:
+ token = result['access_token']
+ expires_in = result.get('expires_in', 7200)
+
+ # 3. 存入 Redis(TTL 比 token 有效期短 60 秒)
+ cache_data = {
+ 'token': token,
+ 'expires_at': time.time() + expires_in
+ }
+ redis_client.setex(
+ cache_key,
+ expires_in - 60,
+ json.dumps(cache_data)
+ )
+
+ print(f"[access_token] 获取成功: appid={appid[:8]}..., expires_in={expires_in}s")
+ return token
+ else:
+ print(f"[access_token] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
+ return None
+
+ except Exception as e:
+ print(f"[access_token] 请求异常: {e}")
+ return None
+
+
+def get_jsapi_ticket_cached(appid, appsecret):
+ """
+ 获取 jsapi_ticket(Redis 缓存)
+ 用于 JS-SDK 签名
+ """
+ cache_key = f"{WECHAT_JSAPI_TICKET_PREFIX}{appid}"
+
+ # 1. 尝试从缓存获取
+ try:
+ cached = redis_client.get(cache_key)
+ if cached:
+ data = json.loads(cached)
+ if data.get('expires_at', 0) > time.time() + 300:
+ print(f"[jsapi_ticket] 使用缓存")
+ return data['ticket']
+ except Exception as e:
+ print(f"[jsapi_ticket] Redis 读取失败: {e}")
+
+ # 2. 获取 access_token
+ access_token = get_wechat_access_token_cached(appid, appsecret)
+ if not access_token:
+ return None
+
+ # 3. 请求 jsapi_ticket
+ url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
+ params = {
+ 'access_token': access_token,
+ 'type': 'jsapi'
+ }
+
+ try:
+ response = requests.get(url, params=params, timeout=10)
+ result = response.json()
+
+ if result.get('errcode') == 0:
+ ticket = result['ticket']
+ expires_in = result.get('expires_in', 7200)
+
+ # 存入 Redis
+ cache_data = {
+ 'ticket': ticket,
+ 'expires_at': time.time() + expires_in
+ }
+ redis_client.setex(
+ cache_key,
+ expires_in - 60,
+ json.dumps(cache_data)
+ )
+
+ print(f"[jsapi_ticket] 获取成功, expires_in={expires_in}s")
+ return ticket
+ else:
+ print(f"[jsapi_ticket] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
+ return None
+
+ except Exception as e:
+ print(f"[jsapi_ticket] 请求异常: {e}")
+ return None
+
+
+def generate_jssdk_signature(url, appid, appsecret):
+ """
+ 生成 JS-SDK 签名配置
+
+ Args:
+ url: 当前页面 URL(不含 # 及其后的部分)
+ appid: 公众号 AppID
+ appsecret: 公众号 AppSecret
+
+ Returns:
+ 签名配置字典,失败返回 None
+ """
+ import hashlib
+
+ # 获取 jsapi_ticket
+ ticket = get_jsapi_ticket_cached(appid, appsecret)
+ if not ticket:
+ return None
+
+ # 生成签名参数
+ timestamp = int(time.time())
+ nonce_str = uuid.uuid4().hex
+
+ # 签名字符串(必须按字典序排序!)
+ sign_str = f"jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}"
+
+ # SHA1 签名
+ signature = hashlib.sha1(sign_str.encode('utf-8')).hexdigest()
+
+ return {
+ 'appId': appid,
+ 'timestamp': timestamp,
+ 'nonceStr': nonce_str,
+ 'signature': signature,
+ 'jsApiList': ['updateAppMessageShareData', 'updateTimelineShareData'],
+ 'openTagList': ['wx-open-launch-weapp']
+ }
+
+
+@app.route('/api/wechat/jssdk-config', methods=['POST'])
+def api_wechat_jssdk_config():
+ """获取微信 JS-SDK 签名配置(用于开放标签)"""
+ try:
+ data = request.get_json() or {}
+ url = data.get('url')
+
+ if not url:
+ return jsonify({
+ 'code': 400,
+ 'message': '缺少必要参数 url',
+ 'data': None
+ }), 400
+
+ # URL 校验:必须是允许的域名
+ from urllib.parse import urlparse
+ parsed = urlparse(url)
+ allowed_domains = ['valuefrontier.cn', 'www.valuefrontier.cn', 'localhost', '127.0.0.1']
+ if parsed.netloc.split(':')[0] not in allowed_domains:
+ return jsonify({
+ 'code': 400,
+ 'message': 'URL 域名不在允许范围内',
+ 'data': None
+ }), 400
+
+ # URL 处理:移除 hash 部分
+ if '#' in url:
+ url = url.split('#')[0]
+
+ # 生成签名(使用公众号配置)
+ config = generate_jssdk_signature(
+ url=url,
+ appid=WECHAT_MP_APPID,
+ appsecret=WECHAT_MP_APPSECRET
+ )
+
+ if not config:
+ return jsonify({
+ 'code': 500,
+ 'message': '获取签名配置失败,请稍后重试',
+ 'data': None
+ }), 500
+
+ return jsonify({
+ 'code': 200,
+ 'message': 'success',
+ 'data': config
+ })
+
+ except Exception as e:
+ print(f"[JS-SDK Config] 异常: {e}")
+ import traceback
+ traceback.print_exc()
+ return jsonify({
+ 'code': 500,
+ 'message': '服务器内部错误',
+ 'data': None
+ }), 500
+
+
+@app.route('/api/miniprogram/url-scheme', methods=['POST'])
+def api_miniprogram_url_scheme():
+ """生成小程序 URL Scheme(外部浏览器跳转小程序用)"""
+ try:
+ # 频率限制
+ client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
+ if client_ip:
+ client_ip = client_ip.split(',')[0].strip()
+
+ rate_key = f"rate_limit:urlscheme:{client_ip}"
+ current = redis_client.incr(rate_key)
+ if current == 1:
+ redis_client.expire(rate_key, 60)
+ if current > 30: # 每分钟最多 30 次
+ return jsonify({
+ 'code': 429,
+ 'message': '请求过于频繁,请稍后再试',
+ 'data': None
+ }), 429
+
+ data = request.get_json() or {}
+
+ # 参数校验
+ path = data.get('path')
+ if path and not path.startswith('/'):
+ path = '/' + path # 自动补全 /
+
+ # 获取小程序 access_token
+ access_token = get_wechat_access_token_cached(
+ WECHAT_MINIPROGRAM_APPID,
+ WECHAT_MINIPROGRAM_APPSECRET
+ )
+ if not access_token:
+ return jsonify({
+ 'code': 500,
+ 'message': '获取访问令牌失败',
+ 'data': None
+ }), 500
+
+ # 构建请求参数
+ wx_url = f"https://api.weixin.qq.com/wxa/generatescheme?access_token={access_token}"
+
+ expire_type = data.get('expire_type', 1)
+ expire_interval = min(data.get('expire_interval', 30), 30) # 最长30天
+
+ payload = {
+ "is_expire": expire_type == 1
+ }
+
+ # 跳转信息
+ if path or data.get('query'):
+ payload["jump_wxa"] = {}
+ if path:
+ payload["jump_wxa"]["path"] = path
+ if data.get('query'):
+ payload["jump_wxa"]["query"] = data.get('query')
+
+ # 有效期设置
+ if expire_type == 1:
+ if data.get('expire_time'):
+ payload["expire_time"] = data.get('expire_time')
+ else:
+ payload["expire_interval"] = expire_interval
+
+ response = requests.post(wx_url, json=payload, timeout=10)
+ result = response.json()
+
+ if result.get('errcode') == 0:
+ return jsonify({
+ 'code': 200,
+ 'message': 'success',
+ 'data': {
+ 'openlink': result['openlink'],
+ 'expire_time': data.get('expire_time') or (int(time.time()) + expire_interval * 86400),
+ 'created_at': datetime.utcnow().isoformat() + 'Z'
+ }
+ })
+ else:
+ print(f"[URL Scheme] 生成失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}")
+ return jsonify({
+ 'code': 500,
+ 'message': f"生成 URL Scheme 失败: {result.get('errmsg', '未知错误')}",
+ 'data': None
+ }), 500
+
+ except Exception as e:
+ print(f"[URL Scheme] 异常: {e}")
+ import traceback
+ traceback.print_exc()
+ return jsonify({
+ 'code': 500,
+ 'message': '服务器内部错误',
+ 'data': None
+ }), 500
+
+
# 评论模型
class EventComment(db.Model):
"""事件评论"""
diff --git a/public/index.html b/public/index.html
index b20b1514..98606493 100755
--- a/public/index.html
+++ b/public/index.html
@@ -186,6 +186,9 @@
href="%PUBLIC_URL%/apple-icon.png"
/>
+
+
+