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