This commit is contained in:
2026-01-13 15:58:04 +08:00
parent 45d5debead
commit 257f1cae69
11 changed files with 1079 additions and 23 deletions

262
apns_models_and_api.py Normal file
View File

@@ -0,0 +1,262 @@
"""
APNs 推送相关的数据库模型和 API 端点
将以下代码添加到 app.py 中
=== 步骤 1: 添加数据库模型(放在其他模型定义附近)===
"""
# ==================== 数据库模型 ====================
class UserDeviceToken(db.Model):
"""用户设备推送 Token"""
__tablename__ = 'user_device_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # 可选,未登录也能注册
device_token = db.Column(db.String(255), unique=True, nullable=False)
platform = db.Column(db.String(20), default='ios') # ios / android
app_version = db.Column(db.String(20), nullable=True)
is_active = db.Column(db.Boolean, default=True)
# 推送订阅设置
subscribe_all = db.Column(db.Boolean, default=True) # 订阅所有事件
subscribe_important = db.Column(db.Boolean, default=True) # 订阅重要事件 (S/A级)
subscribe_types = db.Column(db.Text, nullable=True) # 订阅的事件类型JSON 数组
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
user = db.relationship('User', backref='device_tokens')
"""
=== 步骤 2: 添加 API 端点(放在其他 API 定义附近)===
"""
# ==================== Device Token API ====================
@app.route('/api/users/device-token', methods=['POST'])
def register_device_token():
"""
注册设备推送 Token
App 启动时调用,保存设备 Token 用于推送
"""
try:
data = request.get_json()
device_token = data.get('device_token')
platform = data.get('platform', 'ios')
app_version = data.get('app_version', '1.0.0')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
# 查找是否已存在
existing = UserDeviceToken.query.filter_by(device_token=device_token).first()
if existing:
# 更新现有记录
existing.platform = platform
existing.app_version = app_version
existing.is_active = True
existing.updated_at = datetime.now()
# 如果用户已登录,关联用户
if current_user.is_authenticated:
existing.user_id = current_user.id
else:
# 创建新记录
new_token = UserDeviceToken(
device_token=device_token,
platform=platform,
app_version=app_version,
user_id=current_user.id if current_user.is_authenticated else None,
is_active=True
)
db.session.add(new_token)
db.session.commit()
return jsonify({
'success': True,
'message': 'Device token 注册成功'
})
except Exception as e:
db.session.rollback()
print(f"[API] 注册 device token 失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/users/device-token', methods=['DELETE'])
def unregister_device_token():
"""
取消注册设备 Token用户登出时调用
"""
try:
data = request.get_json()
device_token = data.get('device_token')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
token_record = UserDeviceToken.query.filter_by(device_token=device_token).first()
if token_record:
token_record.is_active = False
token_record.updated_at = datetime.now()
db.session.commit()
return jsonify({
'success': True,
'message': 'Device token 已取消注册'
})
except Exception as e:
db.session.rollback()
print(f"[API] 取消注册 device token 失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/users/push-subscription', methods=['POST'])
def update_push_subscription():
"""
更新推送订阅设置
"""
try:
data = request.get_json()
device_token = data.get('device_token')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
token_record = UserDeviceToken.query.filter_by(device_token=device_token).first()
if not token_record:
return jsonify({'success': False, 'message': 'Device token 不存在'}), 404
# 更新订阅设置
if 'subscribe_all' in data:
token_record.subscribe_all = data['subscribe_all']
if 'subscribe_important' in data:
token_record.subscribe_important = data['subscribe_important']
if 'subscribe_types' in data:
token_record.subscribe_types = json.dumps(data['subscribe_types'])
token_record.updated_at = datetime.now()
db.session.commit()
return jsonify({
'success': True,
'message': '订阅设置已更新'
})
except Exception as e:
db.session.rollback()
print(f"[API] 更新推送订阅失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
"""
=== 步骤 3: 修改 broadcast_new_event 函数,添加 APNs 推送 ===
"""
# 在文件顶部导入
from apns_push import send_event_notification
# 修改 broadcast_new_event 函数
def broadcast_new_event_with_apns(event):
"""
广播新事件到所有订阅的客户端WebSocket + APNs
"""
try:
# === 原有的 WebSocket 推送代码 ===
print(f'\n[WebSocket DEBUG] ========== 广播新事件 ==========')
print(f'[WebSocket DEBUG] 事件ID: {event.id}')
print(f'[WebSocket DEBUG] 事件标题: {event.title}')
print(f'[WebSocket DEBUG] 重要性: {event.importance}')
event_data = {
'id': event.id,
'title': event.title,
'importance': event.importance,
'event_type': event.event_type,
'created_at': event.created_at.isoformat() if event.created_at else None,
'source': event.source,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'hot_score': event.hot_score,
'keywords': event.keywords_list if hasattr(event, 'keywords_list') else event.keywords,
}
# 发送到 WebSocket 房间
socketio.emit('new_event', event_data, room='events_all', namespace='/')
if event.event_type:
room_name = f"events_{event.event_type}"
socketio.emit('new_event', event_data, room=room_name, namespace='/')
# === 新增APNs 推送 ===
# 只推送重要事件 (S/A 级)
if event.importance in ['S', 'A']:
try:
# 获取所有活跃的设备 token
# 可以根据订阅设置过滤
device_tokens_query = db.session.query(UserDeviceToken.device_token).filter(
UserDeviceToken.is_active == True,
db.or_(
UserDeviceToken.subscribe_all == True,
UserDeviceToken.subscribe_important == True
)
).all()
tokens = [t[0] for t in device_tokens_query]
if tokens:
print(f'[APNs] 准备推送到 {len(tokens)} 个设备')
result = send_event_notification(event, tokens)
print(f'[APNs] 推送结果: 成功 {result["success"]}, 失败 {result["failed"]}')
else:
print('[APNs] 没有活跃的设备 token')
except Exception as apns_error:
print(f'[APNs ERROR] 推送失败: {apns_error}')
import traceback
traceback.print_exc()
# 清除事件列表缓存
clear_events_cache()
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
except Exception as e:
print(f'[WebSocket ERROR] 推送新事件失败: {e}')
import traceback
traceback.print_exc()
"""
=== 步骤 4: 创建数据库表(在 MySQL 中执行)===
"""
SQL_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS user_device_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
device_token VARCHAR(255) NOT NULL UNIQUE,
platform VARCHAR(20) DEFAULT 'ios',
app_version VARCHAR(20) NULL,
is_active BOOLEAN DEFAULT TRUE,
subscribe_all BOOLEAN DEFAULT TRUE,
subscribe_important BOOLEAN DEFAULT TRUE,
subscribe_types TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_device_token (device_token),
INDEX idx_user_id (user_id),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
print("请在 MySQL 中执行以下 SQL 创建表:")
print(SQL_CREATE_TABLE)

225
apns_push.py Normal file
View File

@@ -0,0 +1,225 @@
"""
APNs 推送通知模块
用于向 iOS 设备发送推送通知
使用前需要安装:
pip install PyAPNs2
配置:
1. 将 AuthKey_HSF578B626.p8 文件放到服务器安全目录
2. 设置环境变量或修改下面的配置
"""
import os
from datetime import datetime
from typing import List, Optional
from apns2.client import APNsClient, NotificationPriority
from apns2.payload import Payload, PayloadAlert
from apns2.credentials import TokenCredentials
# ==================== APNs 配置 ====================
# APNs 认证配置
APNS_KEY_ID = 'HSF578B626'
APNS_TEAM_ID = '6XML2LHR2J'
APNS_BUNDLE_ID = 'com.valuefrontier.meagent'
# Auth Key 文件路径(基于当前文件位置)
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
APNS_AUTH_KEY_PATH = os.environ.get(
'APNS_AUTH_KEY_PATH',
os.path.join(_BASE_DIR, 'cert', 'AuthKey_HSF578B626.p8')
)
# 是否使用沙盒环境(开发测试用 True生产用 False
APNS_USE_SANDBOX = os.environ.get('APNS_USE_SANDBOX', 'false').lower() == 'true'
# ==================== APNs 客户端初始化 ====================
_apns_client = None
def get_apns_client():
"""获取 APNs 客户端(单例)"""
global _apns_client
if _apns_client is None:
if not os.path.exists(APNS_AUTH_KEY_PATH):
print(f"[APNs] 警告: Auth Key 文件不存在: {APNS_AUTH_KEY_PATH}")
return None
try:
credentials = TokenCredentials(
auth_key_path=APNS_AUTH_KEY_PATH,
auth_key_id=APNS_KEY_ID,
team_id=APNS_TEAM_ID
)
_apns_client = APNsClient(
credentials=credentials,
use_sandbox=APNS_USE_SANDBOX
)
print(f"[APNs] 客户端初始化成功 (sandbox={APNS_USE_SANDBOX})")
except Exception as e:
print(f"[APNs] 客户端初始化失败: {e}")
return None
return _apns_client
# ==================== 推送函数 ====================
def send_push_notification(
device_tokens: List[str],
title: str,
body: str,
data: Optional[dict] = None,
badge: int = 1,
sound: str = "default",
priority: int = 10
) -> dict:
"""
发送推送通知到多个设备
Args:
device_tokens: 设备 Token 列表
title: 通知标题
body: 通知内容
data: 自定义数据(会传递给 App
badge: 角标数字
sound: 提示音
priority: 优先级 (10=立即, 5=省电)
Returns:
dict: {success: int, failed: int, errors: list}
"""
client = get_apns_client()
if not client:
return {'success': 0, 'failed': len(device_tokens), 'errors': ['APNs 客户端未初始化']}
# 构建通知内容
alert = PayloadAlert(title=title, body=body)
payload = Payload(
alert=alert,
sound=sound,
badge=badge,
custom=data or {}
)
# 设置优先级
notification_priority = NotificationPriority.Immediate if priority == 10 else NotificationPriority.PowerConsideration
results = {'success': 0, 'failed': 0, 'errors': []}
for token in device_tokens:
try:
client.send_notification(
token_hex=token,
notification=payload,
topic=APNS_BUNDLE_ID,
priority=notification_priority
)
results['success'] += 1
print(f"[APNs] 推送成功: {token[:20]}...")
except Exception as e:
results['failed'] += 1
results['errors'].append(f"{token[:20]}...: {str(e)}")
print(f"[APNs] 推送失败 {token[:20]}...: {e}")
return results
def send_event_notification(event, device_tokens: List[str]) -> dict:
"""
发送事件推送通知
Args:
event: Event 模型实例
device_tokens: 设备 Token 列表
Returns:
dict: 推送结果
"""
if not device_tokens:
return {'success': 0, 'failed': 0, 'errors': ['没有设备 Token']}
# 根据重要性设置标题
importance_labels = {
'S': '重大事件',
'A': '重要事件',
'B': '一般事件',
'C': '普通事件'
}
title = f"{importance_labels.get(event.importance, '新事件')}"
# 通知内容(截断过长的标题)
body = event.title[:100] + ('...' if len(event.title) > 100 else '')
# 自定义数据
data = {
'event_id': event.id,
'importance': event.importance,
'title': event.title,
'event_type': event.event_type,
'created_at': event.created_at.isoformat() if event.created_at else None
}
return send_push_notification(
device_tokens=device_tokens,
title=title,
body=body,
data=data,
badge=1,
priority=10 if event.importance in ['S', 'A'] else 5
)
# ==================== 数据库操作(需要在 app.py 中实现) ====================
def get_all_device_tokens(db_session) -> List[str]:
"""
获取所有活跃的设备 Token
需要在 app.py 中实现实际的数据库查询
"""
# 示例 SQL:
# SELECT device_token FROM user_device_tokens WHERE is_active = 1
pass
def get_subscribed_device_tokens(db_session, importance_filter: List[str] = None) -> List[str]:
"""
获取订阅了推送的设备 Token
Args:
importance_filter: 重要性过滤,如 ['S', 'A'] 表示只获取订阅了重要事件的用户
"""
# 示例 SQL:
# SELECT device_token FROM user_device_tokens
# WHERE is_active = 1
# AND (subscribe_all = 1 OR subscribe_importance IN ('S', 'A'))
pass
# ==================== 在 app.py 中调用示例 ====================
"""
在 app.py 的 broadcast_new_event() 函数中添加:
from apns_push import send_event_notification
def broadcast_new_event(event):
# ... 现有的 WebSocket 推送代码 ...
# 添加 APNs 推送(只推送重要事件)
if event.importance in ['S', 'A']:
try:
# 获取所有设备 token
device_tokens = db.session.query(UserDeviceToken.device_token).filter(
UserDeviceToken.is_active == True
).all()
tokens = [t[0] for t in device_tokens]
if tokens:
result = send_event_notification(event, tokens)
print(f"[APNs] 事件推送结果: {result}")
except Exception as e:
print(f"[APNs] 推送失败: {e}")
"""

175
app.py
View File

@@ -1382,6 +1382,28 @@ class UserSubscription(db.Model):
}
class UserDeviceToken(db.Model):
"""用户设备推送 Token - 用于 APNs/FCM 推送通知"""
__tablename__ = 'user_device_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) # 可选,未登录也能注册
device_token = db.Column(db.String(255), unique=True, nullable=False)
platform = db.Column(db.String(20), default='ios') # ios / android
app_version = db.Column(db.String(20), nullable=True)
is_active = db.Column(db.Boolean, default=True)
# 推送订阅设置
subscribe_all = db.Column(db.Boolean, default=True) # 订阅所有事件
subscribe_important = db.Column(db.Boolean, default=True) # 订阅重要事件 (S/A级)
subscribe_types = db.Column(db.Text, nullable=True) # 订阅的事件类型JSON 数组
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
user = db.relationship('User', backref='device_tokens')
class SubscriptionPlan(db.Model):
"""订阅套餐表"""
__tablename__ = 'subscription_plans'
@@ -4528,6 +4550,129 @@ def unbind_email():
return jsonify({'error': '解绑失败,请重试'}), 500
# ==================== Device Token API (APNs 推送) ====================
@app.route('/api/device-token', methods=['POST'])
def register_device_token():
"""
注册设备推送 Token
App 启动时调用,保存设备 Token 用于推送
"""
try:
data = request.get_json()
device_token = data.get('device_token')
platform = data.get('platform', 'ios')
app_version = data.get('app_version', '1.0.0')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
# 查找是否已存在
existing = UserDeviceToken.query.filter_by(device_token=device_token).first()
if existing:
# 更新现有记录
existing.platform = platform
existing.app_version = app_version
existing.is_active = True
existing.updated_at = beijing_now()
# 如果用户已登录,关联用户
if session.get('logged_in'):
existing.user_id = session.get('user_id')
else:
# 创建新记录
new_token = UserDeviceToken(
device_token=device_token,
platform=platform,
app_version=app_version,
user_id=session.get('user_id') if session.get('logged_in') else None,
is_active=True
)
db.session.add(new_token)
db.session.commit()
return jsonify({
'success': True,
'message': 'Device token 注册成功'
})
except Exception as e:
db.session.rollback()
print(f"[API] 注册 device token 失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/device-token', methods=['DELETE'])
def unregister_device_token():
"""
取消注册设备 Token用户登出时调用
"""
try:
data = request.get_json()
device_token = data.get('device_token')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
token_record = UserDeviceToken.query.filter_by(device_token=device_token).first()
if token_record:
token_record.is_active = False
token_record.updated_at = beijing_now()
db.session.commit()
return jsonify({
'success': True,
'message': 'Device token 已取消注册'
})
except Exception as e:
db.session.rollback()
print(f"[API] 取消注册 device token 失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/push-subscription', methods=['POST'])
def update_push_subscription():
"""
更新推送订阅设置
"""
try:
data = request.get_json()
device_token = data.get('device_token')
if not device_token:
return jsonify({'success': False, 'message': '缺少 device_token'}), 400
token_record = UserDeviceToken.query.filter_by(device_token=device_token).first()
if not token_record:
return jsonify({'success': False, 'message': 'Device token 不存在'}), 404
# 更新订阅设置
if 'subscribe_all' in data:
token_record.subscribe_all = data['subscribe_all']
if 'subscribe_important' in data:
token_record.subscribe_important = data['subscribe_important']
if 'subscribe_types' in data:
token_record.subscribe_types = json.dumps(data['subscribe_types'])
token_record.updated_at = beijing_now()
db.session.commit()
return jsonify({
'success': True,
'message': '订阅设置已更新'
})
except Exception as e:
db.session.rollback()
print(f"[API] 更新推送订阅失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/auth/register/email', methods=['POST'])
def register_with_email():
"""邮箱注册 - 使用Session"""
@@ -13530,6 +13675,36 @@ def broadcast_new_event(event):
# 清除事件列表缓存,确保用户刷新页面时获取最新数据
clear_events_cache()
# === APNs 推送(只推送重要事件 S/A 级)===
if event.importance in ['S', 'A']:
try:
from apns_push import send_event_notification
# 获取所有活跃的设备 token订阅了重要事件的
device_tokens_query = db.session.query(UserDeviceToken.device_token).filter(
UserDeviceToken.is_active == True,
db.or_(
UserDeviceToken.subscribe_all == True,
UserDeviceToken.subscribe_important == True
)
).all()
tokens = [t[0] for t in device_tokens_query]
if tokens:
print(f'[APNs] 准备推送到 {len(tokens)} 个设备')
result = send_event_notification(event, tokens)
print(f'[APNs] 推送结果: 成功 {result["success"]}, 失败 {result["failed"]}')
else:
print('[APNs] 没有活跃的设备 token')
except ImportError as e:
print(f'[APNs WARN] apns_push 模块未安装或配置: {e}')
except Exception as apns_error:
print(f'[APNs ERROR] 推送失败: {apns_error}')
import traceback
traceback.print_exc()
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
except Exception as e:

View File

@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLshiAgsJoiJegXNC
55cF2MHBnQCi2AaObrf/qgEavcmgCgYIKoZIzj0DAQehRANCAAQoIgTclBUyCDU2
gFaphqK1I4n1VAkEad144GMKxrdjwfAXbOenkDkUis/6LBEMoOI8tBTcwP1qlY7s
V7zdIhb4
-----END PRIVATE KEY-----

View File

@@ -23,15 +23,29 @@
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.valuefrontier.meagent"
"bundleIdentifier": "com.valuefrontier.meagent",
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
}
},
"android": {
"package": "com.valuefrontier.meagent",
"adaptiveIcon": {
"foregroundImage": "./assets/logo.jpg",
"backgroundColor": "#000000"
}
},
"googleServicesFile": "./google-services.json"
},
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/logo.jpg",
"color": "#D4AF37",
"sounds": []
}
]
],
"description": "价值前沿 - 智能投资助手"
}
}

View File

@@ -44,6 +44,9 @@ import { MarketHot, SectorDetail, EventCalendar, StockDetail, TodayStats } from
// 认证页面
import { LoginScreen } from "../src/screens/Auth";
// 推送通知处理
import PushNotificationHandler from "../src/components/PushNotificationHandler";
const { width } = Dimensions.get("screen");
const Stack = createStackNavigator();
@@ -643,28 +646,30 @@ function AppStack(props) {
export default function OnboardingStack(props) {
return (
<Stack.Navigator
screenOptions={{
mode: "card",
headerShown: false,
}}
>
<Stack.Screen
name="Onboarding"
component={Pro}
option={{
headerTransparent: true,
}}
/>
<Stack.Screen name="App" component={AppStack} />
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
presentation: "modal",
<PushNotificationHandler>
<Stack.Navigator
screenOptions={{
mode: "card",
headerShown: false,
}}
/>
</Stack.Navigator>
>
<Stack.Screen
name="Onboarding"
component={Pro}
option={{
headerTransparent: true,
}}
/>
<Stack.Screen name="App" component={AppStack} />
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
presentation: "modal",
headerShown: false,
}}
/>
</Stack.Navigator>
</PushNotificationHandler>
);
}

View File

@@ -25,9 +25,12 @@
"expo": "~51.0.28",
"expo-asset": "~10.0.10",
"expo-blur": "~13.0.3",
"expo-constants": "~16.0.2",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-linear-gradient": "~13.0.2",
"expo-modules-core": "~1.12.24",
"expo-notifications": "~0.28.19",
"expo-splash-screen": "~0.27.5",
"galio-framework": "^0.8.0",
"native-base": "^3.4.28",

View File

@@ -0,0 +1,73 @@
/**
* 推送通知处理器
* 在 Navigation 内部使用,处理推送通知
*/
import { useEffect, useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { pushService } from '../services/pushService';
export function PushNotificationHandler({ children }) {
const navigation = useNavigation();
// 处理收到的通知App 在前台时)
const handleNotificationReceived = useCallback((notification) => {
const data = notification.request.content.data;
console.log('[Push] 前台收到通知:', data);
// 可以触发事件列表刷新等
}, []);
// 处理用户点击通知
const handleNotificationResponse = useCallback((response) => {
const data = response.notification.request.content.data;
console.log('[Push] 用户点击通知:', data);
// 导航到事件详情
if (data?.event_id) {
// 先导航到事件中心,再进入详情
navigation.navigate('App', {
screen: 'EventsDrawer',
params: {
screen: 'EventDetail',
params: {
eventId: data.event_id,
title: data.title || '事件详情',
},
},
});
}
}, [navigation]);
// 初始化推送
useEffect(() => {
const initPush = async () => {
try {
const token = await pushService.initialize(
handleNotificationReceived,
handleNotificationResponse
);
if (token) {
console.log('[Push] 初始化成功Token 已注册');
}
} catch (error) {
console.error('[Push] 初始化失败:', error);
}
};
initPush();
// 清理:不在组件卸载时取消注册,保持后台推送能力
return () => {
// 如果需要完全退出时取消注册,在 logout 处调用
};
}, [handleNotificationReceived, handleNotificationResponse]);
// App 激活时清除角标
useEffect(() => {
pushService.clearBadge();
}, []);
return children;
}
export default PushNotificationHandler;

View File

@@ -0,0 +1,84 @@
/**
* 推送通知 Hook
* 在 App 中初始化和处理推送通知
*/
import { useEffect, useRef, useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { pushService } from '../services/pushService';
/**
* 推送通知 Hook
* @param {object} options
* @param {boolean} options.enabled - 是否启用推送
* @returns {object} { deviceToken, clearBadge }
*/
export function usePushNotifications(options = { enabled: true }) {
const navigation = useNavigation();
const deviceTokenRef = useRef(null);
// 处理收到的通知App 在前台时)
const handleNotificationReceived = useCallback((notification) => {
const data = notification.request.content.data;
console.log('[Push Hook] 前台收到通知:', data);
// 可以在这里触发列表刷新等操作
// 例如dispatch(fetchEvents({ refresh: true }));
}, []);
// 处理用户点击通知
const handleNotificationResponse = useCallback((response) => {
const data = response.notification.request.content.data;
console.log('[Push Hook] 用户点击通知:', data);
// 根据通知数据导航到对应页面
if (data?.event_id) {
// 导航到事件详情
navigation.navigate('EventsDrawer', {
screen: 'EventDetail',
params: {
eventId: data.event_id,
title: data.title || '事件详情',
},
});
}
}, [navigation]);
// 初始化推送服务
useEffect(() => {
if (!options.enabled) return;
const initPush = async () => {
try {
const token = await pushService.initialize(
handleNotificationReceived,
handleNotificationResponse
);
deviceTokenRef.current = token;
console.log('[Push Hook] 推送初始化完成, token:', token ? '已获取' : '未获取');
} catch (error) {
console.error('[Push Hook] 推送初始化失败:', error);
}
};
initPush();
// 清理
return () => {
// 注意:不在这里 unregister因为我们希望保持推送注册
// 如果需要完全退出登录时取消注册,应该在 logout 时调用 pushService.unregister()
};
}, [options.enabled, handleNotificationReceived, handleNotificationResponse]);
// 清除角标
const clearBadge = useCallback(() => {
pushService.clearBadge();
}, []);
return {
deviceToken: deviceTokenRef.current,
clearBadge,
};
}
export default usePushNotifications;

View File

@@ -0,0 +1,203 @@
/**
* 推送通知服务
* 处理 APNs 推送注册和通知处理
*/
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { apiRequest } from './api';
// 配置通知显示方式
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
class PushService {
constructor() {
this.expoPushToken = null;
this.devicePushToken = null;
this.notificationListener = null;
this.responseListener = null;
}
/**
* 初始化推送服务
* @param {function} onNotificationReceived - 收到通知时的回调
* @param {function} onNotificationResponse - 用户点击通知时的回调
*/
async initialize(onNotificationReceived, onNotificationResponse) {
// 注册推送
await this.registerForPushNotifications();
// 监听前台收到的通知
this.notificationListener = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('[Push] 收到通知:', notification);
if (onNotificationReceived) {
onNotificationReceived(notification);
}
}
);
// 监听用户点击通知
this.responseListener = Notifications.addNotificationResponseReceivedListener(
(response) => {
console.log('[Push] 用户点击通知:', response);
if (onNotificationResponse) {
onNotificationResponse(response);
}
}
);
return this.devicePushToken;
}
/**
* 注册推送通知
*/
async registerForPushNotifications() {
// 检查是否是真机
if (!Device.isDevice) {
console.log('[Push] 模拟器不支持推送通知');
return null;
}
// 检查权限
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// 如果没有权限,请求权限
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('[Push] 推送通知权限被拒绝');
return null;
}
try {
// 获取原生 APNs/FCM token用于直接 APNs 推送)
const deviceToken = await Notifications.getDevicePushTokenAsync();
this.devicePushToken = deviceToken.data;
console.log('[Push] Device Token:', this.devicePushToken);
// 发送 token 到后端
await this.sendTokenToServer(this.devicePushToken);
return this.devicePushToken;
} catch (error) {
console.error('[Push] 获取推送 Token 失败:', error);
return null;
}
}
/**
* 发送 device token 到后端保存
*/
async sendTokenToServer(token) {
if (!token) return;
try {
const response = await apiRequest('/api/users/device-token', {
method: 'POST',
body: JSON.stringify({
device_token: token,
platform: Platform.OS,
app_version: Constants.expoConfig?.version || '1.0.0',
}),
});
if (response.success) {
console.log('[Push] Token 已注册到服务器');
} else {
console.warn('[Push] Token 注册失败:', response.message);
}
} catch (error) {
console.error('[Push] 发送 Token 到服务器失败:', error);
}
}
/**
* 更新推送订阅设置
* @param {object} settings - 订阅设置
* @param {boolean} settings.all_events - 订阅所有事件
* @param {boolean} settings.important_only - 只订阅重要事件 (S/A级)
* @param {string[]} settings.event_types - 订阅的事件类型
*/
async updateSubscription(settings) {
if (!this.devicePushToken) {
console.warn('[Push] 无 device token无法更新订阅');
return false;
}
try {
const response = await apiRequest('/api/users/push-subscription', {
method: 'POST',
body: JSON.stringify({
device_token: this.devicePushToken,
...settings,
}),
});
return response.success;
} catch (error) {
console.error('[Push] 更新订阅失败:', error);
return false;
}
}
/**
* 取消注册推送
*/
async unregister() {
if (this.devicePushToken) {
try {
await apiRequest('/api/users/device-token', {
method: 'DELETE',
body: JSON.stringify({
device_token: this.devicePushToken,
}),
});
console.log('[Push] Token 已从服务器移除');
} catch (error) {
console.error('[Push] 移除 Token 失败:', error);
}
}
// 清理监听器
if (this.notificationListener) {
Notifications.removeNotificationSubscription(this.notificationListener);
}
if (this.responseListener) {
Notifications.removeNotificationSubscription(this.responseListener);
}
this.devicePushToken = null;
}
/**
* 清除通知角标
*/
async clearBadge() {
await Notifications.setBadgeCountAsync(0);
}
/**
* 获取当前的 device token
*/
getDeviceToken() {
return this.devicePushToken;
}
}
// 导出单例
export const pushService = new PushService();
export default pushService;

View File

@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLshiAgsJoiJegXNC
55cF2MHBnQCi2AaObrf/qgEavcmgCgYIKoZIzj0DAQehRANCAAQoIgTclBUyCDU2
gFaphqK1I4n1VAkEad144GMKxrdjwfAXbOenkDkUis/6LBEMoOI8tBTcwP1qlY7s
V7zdIhb4
-----END PRIVATE KEY-----