From 257f1cae69c31c44edd6bd244e44ea47254eb411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=B7=E5=B0=8F=E5=89=8D?= Date: Tue, 13 Jan 2026 15:58:04 +0800 Subject: [PATCH] ios app --- apns_models_and_api.py | 262 ++++++++++++++++++ apns_push.py | 225 +++++++++++++++ app.py | 175 ++++++++++++ argon-pro-react-native/AuthKey_HSF578B626.p8 | 6 + argon-pro-react-native/app.json | 18 +- argon-pro-react-native/navigation/Screens.js | 47 ++-- argon-pro-react-native/package.json | 3 + .../src/components/PushNotificationHandler.js | 73 +++++ .../src/hooks/usePushNotifications.js | 84 ++++++ .../src/services/pushService.js | 203 ++++++++++++++ certs/AuthKey_HSF578B626.p8 | 6 + 11 files changed, 1079 insertions(+), 23 deletions(-) create mode 100644 apns_models_and_api.py create mode 100644 apns_push.py create mode 100644 argon-pro-react-native/AuthKey_HSF578B626.p8 create mode 100644 argon-pro-react-native/src/components/PushNotificationHandler.js create mode 100644 argon-pro-react-native/src/hooks/usePushNotifications.js create mode 100644 argon-pro-react-native/src/services/pushService.js create mode 100644 certs/AuthKey_HSF578B626.p8 diff --git a/apns_models_and_api.py b/apns_models_and_api.py new file mode 100644 index 00000000..7b742e20 --- /dev/null +++ b/apns_models_and_api.py @@ -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) diff --git a/apns_push.py b/apns_push.py new file mode 100644 index 00000000..51d3d14f --- /dev/null +++ b/apns_push.py @@ -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}") +""" diff --git a/app.py b/app.py index 9e285d15..125f5f4d 100755 --- a/app.py +++ b/app.py @@ -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: diff --git a/argon-pro-react-native/AuthKey_HSF578B626.p8 b/argon-pro-react-native/AuthKey_HSF578B626.p8 new file mode 100644 index 00000000..e770bd5a --- /dev/null +++ b/argon-pro-react-native/AuthKey_HSF578B626.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLshiAgsJoiJegXNC +55cF2MHBnQCi2AaObrf/qgEavcmgCgYIKoZIzj0DAQehRANCAAQoIgTclBUyCDU2 +gFaphqK1I4n1VAkEad144GMKxrdjwfAXbOenkDkUis/6LBEMoOI8tBTcwP1qlY7s +V7zdIhb4 +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/argon-pro-react-native/app.json b/argon-pro-react-native/app.json index 28ebdc80..b4fee7bf 100644 --- a/argon-pro-react-native/app.json +++ b/argon-pro-react-native/app.json @@ -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": "价值前沿 - 智能投资助手" } } diff --git a/argon-pro-react-native/navigation/Screens.js b/argon-pro-react-native/navigation/Screens.js index 081775f9..9a266804 100644 --- a/argon-pro-react-native/navigation/Screens.js +++ b/argon-pro-react-native/navigation/Screens.js @@ -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 ( - - - - + - + > + + + + + ); } diff --git a/argon-pro-react-native/package.json b/argon-pro-react-native/package.json index 9c9f1b55..4fa85fb3 100644 --- a/argon-pro-react-native/package.json +++ b/argon-pro-react-native/package.json @@ -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", diff --git a/argon-pro-react-native/src/components/PushNotificationHandler.js b/argon-pro-react-native/src/components/PushNotificationHandler.js new file mode 100644 index 00000000..6234865d --- /dev/null +++ b/argon-pro-react-native/src/components/PushNotificationHandler.js @@ -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; diff --git a/argon-pro-react-native/src/hooks/usePushNotifications.js b/argon-pro-react-native/src/hooks/usePushNotifications.js new file mode 100644 index 00000000..f21a5440 --- /dev/null +++ b/argon-pro-react-native/src/hooks/usePushNotifications.js @@ -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; diff --git a/argon-pro-react-native/src/services/pushService.js b/argon-pro-react-native/src/services/pushService.js new file mode 100644 index 00000000..d9d1f32c --- /dev/null +++ b/argon-pro-react-native/src/services/pushService.js @@ -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; diff --git a/certs/AuthKey_HSF578B626.p8 b/certs/AuthKey_HSF578B626.p8 new file mode 100644 index 00000000..e770bd5a --- /dev/null +++ b/certs/AuthKey_HSF578B626.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLshiAgsJoiJegXNC +55cF2MHBnQCi2AaObrf/qgEavcmgCgYIKoZIzj0DAQehRANCAAQoIgTclBUyCDU2 +gFaphqK1I4n1VAkEad144GMKxrdjwfAXbOenkDkUis/6LBEMoOI8tBTcwP1qlY7s +V7zdIhb4 +-----END PRIVATE KEY----- \ No newline at end of file