diff --git a/docs/ios-push-implementation-plan.md b/docs/ios-push-implementation-plan.md new file mode 100644 index 0000000..6c73d77 --- /dev/null +++ b/docs/ios-push-implementation-plan.md @@ -0,0 +1,288 @@ +# iOS远程推送功能实施计划 + +## 项目概述 + +本文档详细描述了在现有NestJS项目中实现iOS远程推送功能的完整实施计划。该功能将使用Apple官方APNs服务,通过@parse/node-apn库与Apple推送服务进行通信。 + +## 技术选型 + +### 核心技术栈 +- **推送服务**: Apple官方APNs (Apple Push Notification service) +- **Node.js库**: @parse/node-apn (Trust Score: 9.8,支持HTTP/2) +- **认证方式**: Token-based authentication (推荐) +- **数据库**: MySQL (与现有项目保持一致) + +### 依赖包 +```json +{ + "@parse/node-apn": "^5.0.0", + "uuid": "^11.1.0" // 已存在 +} +``` + +## 实施阶段 + +### 第一阶段:基础设施搭建 +1. **创建推送模块结构** + - 创建`src/push-notifications/`目录 + - 设置模块、控制器、服务的基础结构 + +2. **数据库设计与实现** + - 创建推送令牌表 (t_user_push_tokens) + - 创建推送消息表 (t_push_messages) + - 创建推送模板表 (t_push_templates) + - 编写数据库迁移脚本 + +3. **APNs连接配置** + - 配置APNs认证信息 + - 实现APNs Provider服务 + - 设置连接池和错误处理 + +### 第二阶段:核心功能实现 +1. **推送令牌管理** + - 实现设备令牌注册/更新/注销 + - 令牌有效性验证 + - 无效令牌清理机制 + +2. **推送消息发送** + - 实现单个推送发送 + - 实现批量推送发送 + - 实现静默推送发送 + +3. **推送模板系统** + - 模板创建/更新/删除 + - 模板渲染引擎 + - 动态数据绑定 + +### 第三阶段:API接口开发 +1. **推送令牌管理API** + - POST /api/push-notifications/register-token + - PUT /api/push-notifications/update-token + - DELETE /api/push-notifications/unregister-token + +2. **推送消息发送API** + - POST /api/push-notifications/send + - POST /api/push-notifications/send-by-template + - POST /api/push-notifications/send-batch + +3. **推送模板管理API** + - GET /api/push-notifications/templates + - POST /api/push-notifications/templates + - PUT /api/push-notifications/templates/:id + - DELETE /api/push-notifications/templates/:id + +### 第四阶段:优化与监控 +1. **性能优化** + - 连接池管理 + - 批量处理优化 + - 缓存策略实现 + +2. **错误处理与重试** + - APNs错误分类处理 + - 指数退避重试机制 + - 无效令牌自动清理 + +3. **日志与监控** + - 推送状态日志记录 + - 性能指标监控 + - 错误率统计 + +## 文件结构 + +``` +src/push-notifications/ +├── push-notifications.module.ts +├── push-notifications.controller.ts +├── push-notifications.service.ts +├── apns.provider.ts +├── push-token.service.ts +├── push-template.service.ts +├── push-message.service.ts +├── models/ +│ ├── user-push-token.model.ts +│ ├── push-message.model.ts +│ └── push-template.model.ts +├── dto/ +│ ├── register-device-token.dto.ts +│ ├── update-device-token.dto.ts +│ ├── send-push-notification.dto.ts +│ ├── send-push-by-template.dto.ts +│ ├── create-push-template.dto.ts +│ ├── update-push-template.dto.ts +│ └── push-response.dto.ts +├── interfaces/ +│ ├── push-notification.interface.ts +│ ├── apns-config.interface.ts +│ └── push-stats.interface.ts +└── enums/ + ├── device-type.enum.ts + ├── push-type.enum.ts + └── push-message-status.enum.ts +``` + +## 环境配置 + +### 环境变量 +```bash +# APNs配置 +APNS_KEY_ID=your_key_id +APNS_TEAM_ID=your_team_id +APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8 +APNS_BUNDLE_ID=com.yourcompany.yourapp +APNS_ENVIRONMENT=production # or sandbox + +# 推送服务配置 +PUSH_RETRY_LIMIT=3 +PUSH_REQUEST_TIMEOUT=5000 +PUSH_HEARTBEAT=60000 +PUSH_BATCH_SIZE=100 +``` + +### APNs认证文件 +- 需要从Apple开发者账号下载.p8格式的私钥文件 +- 将私钥文件安全地存储在服务器上 + +## 数据库表结构 + +### 推送令牌表 (t_user_push_tokens) +```sql +CREATE TABLE t_user_push_tokens ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS', + app_version VARCHAR(50), + os_version VARCHAR(50), + device_name VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + last_used_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_device_token (device_token), + INDEX idx_user_device (user_id, device_token), + UNIQUE KEY uk_user_device_token (user_id, device_token) +); +``` + +### 推送消息表 (t_push_messages) +```sql +CREATE TABLE t_push_messages ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + message_type VARCHAR(50) NOT NULL, + title VARCHAR(255), + body TEXT, + payload JSON, + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT', + priority TINYINT DEFAULT 10, + expiry DATETIME, + collapse_id VARCHAR(64), + status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') DEFAULT 'PENDING', + apns_response JSON, + error_message TEXT, + sent_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_message_type (message_type) +); +``` + +### 推送模板表 (t_push_templates) +```sql +CREATE TABLE t_push_templates ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + template_key VARCHAR(100) NOT NULL UNIQUE, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + payload_template JSON, + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT', + priority TINYINT DEFAULT 10, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_template_key (template_key), + INDEX idx_is_active (is_active) +); +``` + +## 使用示例 + +### 1. 注册设备令牌 +```typescript +// iOS客户端获取设备令牌后,调用此API +POST /api/push-notifications/register-token +{ + "deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7", + "deviceType": "IOS", + "appVersion": "1.0.0", + "osVersion": "iOS 15.0", + "deviceName": "iPhone 13" +} +``` + +### 2. 发送推送通知 +```typescript +// 在业务服务中调用推送服务 +await this.pushNotificationsService.sendNotification({ + userIds: ['user_123'], + title: '训练提醒', + body: '您今天的普拉提训练还未完成,快来打卡吧!', + payload: { + type: 'training_reminder', + trainingId: 'training_123' + } +}); +``` + +### 3. 使用模板发送推送 +```typescript +// 使用预定义模板发送推送 +await this.pushNotificationsService.sendNotificationByTemplate( + 'user_123', + 'training_reminder', + { + userName: '张三', + trainingName: '核心力量训练' + } +); +``` + +## 预期收益 + +1. **用户体验提升**: 及时推送训练提醒、饮食记录等重要信息 +2. **用户粘性增强**: 通过个性化推送提高用户活跃度 +3. **业务目标达成**: 支持各种业务场景的推送需求 +4. **技术架构完善**: 建立可扩展的推送服务架构 + +## 风险评估 + +### 技术风险 +- **APNs连接稳定性**: 通过连接池和重试机制降低风险 +- **推送令牌管理**: 实现自动清理和验证机制 +- **性能瓶颈**: 通过批量处理和缓存优化解决 + +### 业务风险 +- **用户隐私**: 严格遵守数据保护法规 +- **推送频率**: 实现推送频率限制避免骚扰用户 +- **内容审核**: 建立推送内容审核机制 + +## 后续扩展 + +1. **多平台支持**: 扩展Android推送功能 +2. **推送策略**: 实现智能推送时机和内容优化 +3. **数据分析**: 推送效果分析和用户行为追踪 +4. **A/B测试**: 推送内容和策略的A/B测试功能 + +## 总结 + +本实施计划提供了一个完整的iOS远程推送功能解决方案,包括技术选型、架构设计、实施步骤和使用示例。该方案具有良好的可扩展性和维护性,能够满足当前业务需求并为未来扩展留有空间。 + +实施完成后,您将拥有一个功能完整、性能优良的推送服务系统,可以通过简单的API调用来发送各种类型的推送通知,提升用户体验和业务指标。 \ No newline at end of file diff --git a/docs/ios-push-notification-design.md b/docs/ios-push-notification-design.md new file mode 100644 index 0000000..d4262cf --- /dev/null +++ b/docs/ios-push-notification-design.md @@ -0,0 +1,265 @@ +# iOS远程推送功能设计方案 + +## 1. 技术方案概述 + +### 1.1 推送服务选择 +- **服务提供商**: Apple官方APNs (Apple Push Notification service) +- **Node.js库**: @parse/node-apn (Trust Score: 9.8,支持HTTP/2,维护良好) +- **认证方式**: Token-based authentication (推荐) 或 Certificate-based authentication + +### 1.2 技术架构 +``` +iOS App -> APNs -> 后端服务器 (NestJS) -> APNs Provider -> iOS设备 +``` + +### 1.3 核心组件 +1. **推送令牌管理**: 存储和管理设备推送令牌 +2. **APNs服务**: 与Apple推送服务通信 +3. **消息模板系统**: 管理推送消息内容 +4. **推送日志**: 记录推送状态和结果 +5. **API接口**: 提供推送令牌注册和推送发送功能 + +## 2. 数据库设计 + +### 2.1 推送令牌表 (t_user_push_tokens) +```sql +CREATE TABLE t_user_push_tokens ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS', + app_version VARCHAR(50), + os_version VARCHAR(50), + device_name VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + last_used_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_device_token (device_token), + INDEX idx_user_device (user_id, device_token), + UNIQUE KEY uk_user_device_token (user_id, device_token) +); +``` + +### 2.2 推送消息表 (t_push_messages) +```sql +CREATE TABLE t_push_messages ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + message_type VARCHAR(50) NOT NULL, + title VARCHAR(255), + body TEXT, + payload JSON, + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT', + priority TINYINT DEFAULT 10, + expiry DATETIME, + collapse_id VARCHAR(64), + status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') DEFAULT 'PENDING', + apns_response JSON, + error_message TEXT, + sent_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_message_type (message_type) +); +``` + +### 2.3 推送模板表 (t_push_templates) +```sql +CREATE TABLE t_push_templates ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + template_key VARCHAR(100) NOT NULL UNIQUE, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + payload_template JSON, + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT', + priority TINYINT DEFAULT 10, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_template_key (template_key), + INDEX idx_is_active (is_active) +); +``` + +## 3. 服务架构设计 + +### 3.1 模块结构 +``` +src/push-notifications/ +├── push-notifications.module.ts +├── push-notifications.controller.ts +├── push-notifications.service.ts +├── apns.provider.ts +├── models/ +│ ├── user-push-token.model.ts +│ ├── push-message.model.ts +│ └── push-template.model.ts +├── dto/ +│ ├── register-device-token.dto.ts +│ ├── send-push-notification.dto.ts +│ ├── push-template.dto.ts +│ └── push-response.dto.ts +└── interfaces/ + ├── push-notification.interface.ts + └── apns-config.interface.ts +``` + +### 3.2 核心服务类 +1. **PushNotificationsService**: 主要业务逻辑服务 +2. **ApnsProvider**: APNs连接和通信服务 +3. **PushTokenService**: 推送令牌管理服务 +4. **PushTemplateService**: 推送模板管理服务 + +## 4. API接口设计 + +### 4.1 推送令牌管理 +``` +POST /api/push-notifications/register-token +PUT /api/push-notifications/update-token +DELETE /api/push-notifications/unregister-token +``` + +### 4.2 推送消息发送 +``` +POST /api/push-notifications/send +POST /api/push-notifications/send-by-template +POST /api/push-notifications/send-batch +``` + +### 4.3 推送模板管理 +``` +GET /api/push-notifications/templates +POST /api/push-notifications/templates +PUT /api/push-notifications/templates/:id +DELETE /api/push-notifications/templates/:id +``` + +## 5. 配置要求 + +### 5.1 环境变量 +```bash +# APNs配置 +APNS_KEY_ID=your_key_id +APNS_TEAM_ID=your_team_id +APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8 +APNS_BUNDLE_ID=com.yourcompany.yourapp +APNS_ENVIRONMENT=production # or sandbox + +# 推送服务配置 +PUSH_RETRY_LIMIT=3 +PUSH_REQUEST_TIMEOUT=5000 +PUSH_HEARTBEAT=60000 +``` + +### 5.2 APNs认证配置 +- **Token-based认证** (推荐): + - Key ID: 从Apple开发者账号获取 + - Team ID: 从Apple开发者账号获取 + - 私钥文件: .p8格式的私钥文件 + +- **Certificate-based认证**: + - 证书文件: .pem格式的证书文件 + - 私钥文件: .pem格式的私钥文件 + +## 6. 推送消息类型 + +### 6.1 基础推送类型 +- **ALERT**: 标准推送通知,显示警告、播放声音或更新应用图标徽章 +- **BACKGROUND**: 静默推送,不显示用户界面 +- **VOIP**: VoIP推送,用于实时通信 +- **LIVEACTIVITY**: 实时活动推送 + +### 6.2 业务场景推送 +- 训练提醒 +- 饮食记录提醒 +- 挑战进度通知 +- 会员到期提醒 +- 系统通知 + +## 7. 错误处理和重试机制 + +### 7.1 常见错误处理 +- **Unregistered**: 设备令牌无效,从数据库中删除 +- **BadDeviceToken**: 设备令牌格式错误,记录并标记为无效 +- **DeviceTokenNotForTopic**: 设备令牌与Bundle ID不匹配 +- **TooManyRequests**: 请求频率过高,实现退避重试 +- **InternalServerError**: APNs服务器错误,实现重试机制 + +### 7.2 重试策略 +- 指数退避算法 +- 最大重试次数限制 +- 不同错误类型的差异化处理 + +## 8. 安全考虑 + +### 8.1 数据安全 +- 推送令牌加密存储 +- 敏感信息脱敏日志 +- API访问权限控制 + +### 8.2 隐私保护 +- 用户推送偏好设置 +- 推送内容审核机制 +- 推送频率限制 + +## 9. 监控和日志 + +### 9.1 推送监控 +- 推送成功率统计 +- 推送延迟监控 +- 错误率分析 + +### 9.2 日志记录 +- 推送请求日志 +- APNs响应日志 +- 错误详情日志 + +## 10. 性能优化 + +### 10.1 连接管理 +- HTTP/2连接池 +- 连接复用 +- 心跳保活 + +### 10.2 批量处理 +- 批量推送优化 +- 异步处理机制 +- 队列管理 + +## 11. 测试策略 + +### 11.1 单元测试 +- 服务层逻辑测试 +- 数据模型测试 +- 工具函数测试 + +### 11.2 集成测试 +- APNs连接测试 +- 推送流程测试 +- 错误处理测试 + +### 11.3 端到端测试 +- 沙盒环境测试 +- 真机推送测试 +- 性能压力测试 + +## 12. 部署和运维 + +### 12.1 环境配置 +- 开发环境: 使用APNs沙盒环境 +- 测试环境: 使用APNs沙盒环境 +- 生产环境: 使用APNs生产环境 + +### 12.2 运维监控 +- 推送服务健康检查 +- 性能指标监控 +- 告警机制设置 \ No newline at end of file diff --git a/docs/push-notifications-usage-guide.md b/docs/push-notifications-usage-guide.md new file mode 100644 index 0000000..021303c --- /dev/null +++ b/docs/push-notifications-usage-guide.md @@ -0,0 +1,474 @@ +# iOS推送功能使用指南 + +## 概述 + +本文档详细介绍了如何使用iOS远程推送功能,包括API接口使用、配置说明和代码示例。 + +## 环境配置 + +### 1. 环境变量配置 + +在`.env`文件中添加以下配置: + +```bash +# APNs配置 +APNS_KEY_ID=your_key_id +APNS_TEAM_ID=your_team_id +APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8 +APNS_BUNDLE_ID=com.yourcompany.yourapp +APNS_ENVIRONMENT=production # or sandbox + +# 推送服务配置 +APNS_CLIENT_COUNT=2 +APNS_CONNECTION_RETRY_LIMIT=3 +APNS_HEARTBEAT=60000 +APNS_REQUEST_TIMEOUT=5000 +``` + +### 2. APNs认证文件 + +1. 登录 [Apple Developer Portal](https://developer.apple.com) +2. 导航到 "Certificates, Identifiers & Profiles" +3. 选择 "Keys" +4. 创建新的密钥,并启用 "Apple Push Notifications service" +5. 下载`.p8`格式的私钥文件 +6. 将私钥文件安全地存储在服务器上 + +### 3. 数据库迁移 + +执行以下SQL脚本创建推送相关的数据表: + +```bash +mysql -u username -p database_name < sql-scripts/push-notifications-tables-create.sql +``` + +## API接口使用 + +### 1. 设备令牌管理 + +#### 注册设备令牌 + +```bash +POST /api/push-notifications/register-token +Authorization: Bearer +Content-Type: application/json + +{ + "deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7", + "deviceType": "IOS", + "appVersion": "1.0.0", + "osVersion": "iOS 15.0", + "deviceName": "iPhone 13" +} +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "设备令牌注册成功", + "data": { + "success": true, + "tokenId": "uuid-token-id" + } +} +``` + +#### 更新设备令牌 + +```bash +PUT /api/push-notifications/update-token +Authorization: Bearer +Content-Type: application/json + +{ + "currentDeviceToken": "old-device-token", + "newDeviceToken": "new-device-token", + "appVersion": "1.0.1", + "osVersion": "iOS 15.1" +} +``` + +#### 注销设备令牌 + +```bash +DELETE /api/push-notifications/unregister-token +Authorization: Bearer +Content-Type: application/json + +{ + "deviceToken": "device-token-to-unregister" +} +``` + +### 2. 推送消息发送 + +#### 发送单个推送通知 + +```bash +POST /api/push-notifications/send +Content-Type: application/json + +{ + "userIds": ["user_123", "user_456"], + "title": "训练提醒", + "body": "您今天的普拉提训练还未完成,快来打卡吧!", + "payload": { + "type": "training_reminder", + "trainingId": "training_123" + }, + "pushType": "ALERT", + "priority": 10, + "sound": "default", + "badge": 1 +} +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "推送发送成功", + "data": { + "success": true, + "sentCount": 2, + "failedCount": 0, + "results": [ + { + "userId": "user_123", + "deviceToken": "device-token-1", + "success": true + }, + { + "userId": "user_456", + "deviceToken": "device-token-2", + "success": true + } + ] + } +} +``` + +#### 使用模板发送推送 + +```bash +POST /api/push-notifications/send-by-template +Content-Type: application/json + +{ + "userIds": ["user_123"], + "templateKey": "training_reminder", + "data": { + "userName": "张三", + "trainingName": "核心力量训练" + }, + "payload": { + "type": "training_reminder", + "trainingId": "training_123" + } +} +``` + +#### 批量发送推送 + +```bash +POST /api/push-notifications/send-batch +Content-Type: application/json + +{ + "userIds": ["user_123", "user_456", "user_789"], + "title": "系统通知", + "body": "系统将于今晚22:00进行维护,请提前保存您的工作。", + "payload": { + "type": "system_maintenance", + "maintenanceTime": "22:00" + } +} +``` + +#### 发送静默推送 + +```bash +POST /api/push-notifications/send-silent +Content-Type: application/json + +{ + "userId": "user_123", + "payload": { + "type": "data_sync", + "syncData": true + } +} +``` + +### 3. 推送模板管理 + +#### 获取所有模板 + +```bash +GET /api/push-notifications/templates +Authorization: Bearer +``` + +#### 创建推送模板 + +```bash +POST /api/push-notifications/templates +Authorization: Bearer +Content-Type: application/json + +{ + "templateKey": "custom_reminder", + "title": "自定义提醒", + "body": "您好{{userName}},{{reminderContent}}", + "payloadTemplate": { + "type": "custom_reminder", + "reminderId": "{{reminderId}}" + }, + "pushType": "ALERT", + "priority": 8 +} +``` + +#### 更新推送模板 + +```bash +PUT /api/push-notifications/templates/:id +Authorization: Bearer +Content-Type: application/json + +{ + "title": "更新后的标题", + "body": "更新后的内容:{{userName}},{{reminderContent}}", + "isActive": true +} +``` + +#### 删除推送模板 + +```bash +DELETE /api/push-notifications/templates/:id +Authorization: Bearer +``` + +## 代码示例 + +### 1. 在业务服务中使用推送功能 + +```typescript +import { Injectable } from '@nestjs/common'; +import { PushNotificationsService } from '../push-notifications/push-notifications.service'; + +@Injectable() +export class TrainingService { + constructor( + private readonly pushNotificationsService: PushNotificationsService, + ) {} + + async sendTrainingReminder(userId: string, trainingName: string): Promise { + // 使用模板发送推送 + await this.pushNotificationsService.sendNotificationByTemplate({ + userIds: [userId], + templateKey: 'training_reminder', + data: { + userName: '用户', // 可以从用户服务获取 + trainingName, + }, + payload: { + type: 'training_reminder', + trainingId: 'training_123', + }, + }); + } + + async sendWorkoutCompletionNotification(userId: string, workoutName: string, calories: number): Promise { + // 直接发送推送 + await this.pushNotificationsService.sendNotification({ + userIds: [userId], + title: '训练完成', + body: `太棒了!您已完成${workoutName}训练,消耗了${calories}卡路里。`, + payload: { + type: 'workout_completed', + workoutId: 'workout_123', + calories, + }, + sound: 'celebration.caf', + badge: 1, + }); + } +} +``` + +### 2. 在控制器中处理设备令牌注册 + +```typescript +import { Controller, Post, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { PushNotificationsService } from '../push-notifications/push-notifications.service'; +import { RegisterDeviceTokenDto } from '../push-notifications/dto/register-device-token.dto'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@ApiTags('用户设备') +@Controller('user/device') +@UseGuards(JwtAuthGuard) +export class UserDeviceController { + constructor( + private readonly pushNotificationsService: PushNotificationsService, + ) {} + + @Post('register-token') + @ApiOperation({ summary: '注册设备推送令牌' }) + async registerDeviceToken( + @CurrentUser() user: AccessTokenPayload, + @Body() registerTokenDto: RegisterDeviceTokenDto, + ) { + return this.pushNotificationsService.registerToken(user.sub, registerTokenDto); + } +} +``` + +## 预定义推送模板 + +系统已预置以下推送模板: + +### 1. 训练提醒 (training_reminder) +- **用途**: 提醒用户完成训练 +- **变量**: `{{userName}}`, `{{trainingName}}` +- **示例**: "您好张三,您今天的核心力量训练还未完成,快来打卡吧!" + +### 2. 饮食记录提醒 (diet_record_reminder) +- **用途**: 提醒用户记录饮食 +- **变量**: `{{userName}}` +- **示例**: "您好张三,您还没有记录今天的饮食,记得及时记录哦!" + +### 3. 挑战进度 (challenge_progress) +- **用途**: 通知用户挑战进度 +- **变量**: `{{challengeName}}`, `{{progress}}` +- **示例**: "恭喜您!您已完成30天挑战的50%,继续加油!" + +### 4. 会员到期提醒 (membership_expiring) +- **用途**: 提醒用户会员即将到期 +- **变量**: `{{userName}}`, `{{days}}` +- **示例**: "您好张三,您的会员将在7天后到期,请及时续费以免影响使用。" + +### 5. 会员已到期 (membership_expired) +- **用途**: 通知用户会员已到期 +- **变量**: `{{userName}}` +- **示例**: "您好张三,您的会员已到期,请续费以继续享受会员服务。" + +### 6. 成就解锁 (achievement_unlocked) +- **用途**: 庆祝用户解锁成就 +- **变量**: `{{achievementName}}` +- **示例**: "恭喜您解锁了"连续训练7天"成就!" + +### 7. 训练完成 (workout_completed) +- **用途**: 确认用户完成训练 +- **变量**: `{{workoutName}}`, `{{calories}}` +- **示例**: "太棒了!您已完成核心力量训练,消耗了150卡路里。" + +## 错误处理 + +### 常见错误码 + +| 错误码 | 描述 | 解决方案 | +|--------|------|----------| +| 400 | 请求参数错误 | 检查请求参数格式和必填字段 | +| 401 | 未授权访问 | 确保提供了有效的访问令牌 | +| 404 | 资源不存在 | 检查用户ID或模板键是否正确 | +| 429 | 请求频率过高 | 降低请求频率,实现退避重试 | +| 500 | 服务器内部错误 | 检查服务器日志,联系技术支持 | + +### APNs错误处理 + +系统会自动处理以下APNs错误: + +- **Unregistered**: 自动停用无效的设备令牌 +- **BadDeviceToken**: 记录错误并停用令牌 +- **DeviceTokenNotForTopic**: 记录错误日志 +- **TooManyRequests**: 实现退避重试机制 +- **InternalServerError**: 自动重试 + +## 监控和日志 + +### 1. 推送状态监控 + +可以通过以下方式监控推送状态: + +```typescript +// 获取推送统计 +const stats = await this.pushMessageService.getMessageStats(); +console.log(`推送成功率: ${stats.successRate}%`); +console.log(`错误分布:`, stats.errorBreakdown); +``` + +### 2. 日志查看 + +推送相关日志包含以下信息: +- 推送请求和响应 +- APNs连接状态 +- 错误详情和堆栈跟踪 +- 性能指标 + +## 最佳实践 + +### 1. 推送时机 +- 避免在深夜或凌晨发送推送 +- 根据用户时区调整推送时间 +- 尊重用户的推送偏好设置 + +### 2. 推送内容 +- 保持推送内容简洁明了 +- 使用个性化内容提高用户参与度 +- 避免发送过于频繁的推送 + +### 3. 性能优化 +- 使用批量推送减少网络请求 +- 实现推送优先级管理 +- 定期清理无效的设备令牌 + +### 4. 安全考虑 +- 保护用户隐私数据 +- 实现推送内容审核机制 +- 使用HTTPS进行API通信 + +## 故障排除 + +### 1. 推送不生效 +- 检查APNs配置是否正确 +- 验证设备令牌是否有效 +- 确认Bundle ID是否匹配 + +### 2. 推送延迟 +- 检查网络连接状态 +- 验证APNs服务器状态 +- 调整推送优先级设置 + +### 3. 设备令牌失效 +- 实现令牌自动更新机制 +- 定期清理无效令牌 +- 监控令牌失效率 + +## 扩展功能 + +### 1. 推送统计分析 +- 实现推送打开率统计 +- 分析用户行为数据 +- 优化推送策略 + +### 2. A/B测试 +- 实现推送内容A/B测试 +- 比较不同推送策略效果 +- 优化推送转化率 + +### 3. 多平台支持 +- 扩展Android推送功能 +- 统一推送接口设计 +- 实现平台特定功能 + +## 总结 + +iOS推送功能已完全集成到系统中,提供了完整的推送令牌管理、消息发送和模板系统。通过遵循本指南,您可以轻松地在应用中实现各种推送场景,提升用户体验和参与度。 + +如有任何问题或需要进一步的技术支持,请参考相关文档或联系开发团队。 \ No newline at end of file diff --git a/docs/push-service-architecture.md b/docs/push-service-architecture.md new file mode 100644 index 0000000..9b2f898 --- /dev/null +++ b/docs/push-service-architecture.md @@ -0,0 +1,673 @@ +# iOS推送服务架构和接口设计 + +## 1. 服务架构概览 + +### 1.1 整体架构图 +```mermaid +graph TB + A[iOS App] --> B[APNs] + B --> C[NestJS Push Service] + C --> D[APNs Provider] + D --> B + C --> E[Push Token Service] + C --> F[Push Template Service] + C --> G[Push Message Service] + C --> H[Database] + E --> H + F --> H + G --> H +``` + +### 1.2 模块依赖关系 +```mermaid +graph LR + A[PushNotificationsModule] --> B[PushNotificationsController] + A --> C[PushNotificationsService] + A --> D[ApnsProvider] + A --> E[PushTokenService] + A --> F[PushTemplateService] + A --> G[PushMessageService] + A --> H[DatabaseModule] + A --> I[ConfigModule] +``` + +## 2. 核心服务类设计 + +### 2.1 PushNotificationsService +```typescript +@Injectable() +export class PushNotificationsService { + constructor( + private readonly apnsProvider: ApnsProvider, + private readonly pushTokenService: PushTokenService, + private readonly pushTemplateService: PushTemplateService, + private readonly pushMessageService: PushMessageService, + private readonly logger: Logger, + ) {} + + // 发送单个推送 + async sendNotification(userId: string, notification: PushNotificationDto): Promise + + // 批量发送推送 + async sendBatchNotifications(userIds: string[], notification: PushNotificationDto): Promise + + // 使用模板发送推送 + async sendNotificationByTemplate(userId: string, templateKey: string, data: any): Promise + + // 发送静默推送 + async sendSilentNotification(userId: string, payload: any): Promise +} +``` + +### 2.2 ApnsProvider +```typescript +@Injectable() +export class ApnsProvider { + private provider: apn.Provider; + private multiProvider: apn.MultiProvider; + + constructor(private readonly configService: ConfigService) { + this.initializeProvider(); + } + + // 初始化APNs连接 + private initializeProvider(): void + + // 发送单个通知 + async send(notification: apn.Notification, deviceTokens: string[]): Promise + + // 批量发送通知 + async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise + + // 管理推送通道 + async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise + + // 广播实时活动通知 + async broadcast(notification: apn.Notification, bundleId: string): Promise + + // 关闭连接 + shutdown(): void +} +``` + +### 2.3 PushTokenService +```typescript +@Injectable() +export class PushTokenService { + constructor( + @InjectModel(UserPushToken) private readonly pushTokenModel: typeof UserPushToken, + ) {} + + // 注册设备令牌 + async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise + + // 更新设备令牌 + async updateToken(userId: string, tokenData: UpdateDeviceTokenDto): Promise + + // 注销设备令牌 + async unregisterToken(userId: string, deviceToken: string): Promise + + // 获取用户的所有有效令牌 + async getActiveTokens(userId: string): Promise + + // 清理无效令牌 + async cleanupInvalidTokens(): Promise + + // 验证令牌有效性 + async validateToken(deviceToken: string): Promise +} +``` + +### 2.4 PushTemplateService +```typescript +@Injectable() +export class PushTemplateService { + constructor( + @InjectModel(PushTemplate) private readonly templateModel: typeof PushTemplate, + ) {} + + // 创建推送模板 + async createTemplate(templateData: CreatePushTemplateDto): Promise + + // 更新推送模板 + async updateTemplate(id: string, templateData: UpdatePushTemplateDto): Promise + + // 删除推送模板 + async deleteTemplate(id: string): Promise + + // 获取模板 + async getTemplate(templateKey: string): Promise + + // 获取所有模板 + async getAllTemplates(): Promise + + // 渲染模板 + async renderTemplate(templateKey: string, data: any): Promise +} +``` + +### 2.5 PushMessageService +```typescript +@Injectable() +export class PushMessageService { + constructor( + @InjectModel(PushMessage) private readonly messageModel: typeof PushMessage, + ) {} + + // 创建推送消息记录 + async createMessage(messageData: CreatePushMessageDto): Promise + + // 更新消息状态 + async updateMessageStatus(id: string, status: PushMessageStatus, response?: any): Promise + + // 获取消息历史 + async getMessageHistory(userId: string, options: QueryOptions): Promise + + // 获取消息统计 + async getMessageStats(userId?: string, timeRange?: TimeRange): Promise + + // 清理过期消息 + async cleanupExpiredMessages(): Promise +} +``` + +## 3. 数据传输对象(DTO)设计 + +### 3.1 推送令牌相关DTO +```typescript +// 注册设备令牌 +export class RegisterDeviceTokenDto { + @ApiProperty({ description: '设备推送令牌' }) + @IsString() + @IsNotEmpty() + deviceToken: string; + + @ApiProperty({ description: '设备类型', enum: DeviceType }) + @IsEnum(DeviceType) + deviceType: DeviceType; + + @ApiProperty({ description: '应用版本', required: false }) + @IsString() + @IsOptional() + appVersion?: string; + + @ApiProperty({ description: '操作系统版本', required: false }) + @IsString() + @IsOptional() + osVersion?: string; + + @ApiProperty({ description: '设备名称', required: false }) + @IsString() + @IsOptional() + deviceName?: string; +} + +// 更新设备令牌 +export class UpdateDeviceTokenDto { + @ApiProperty({ description: '新的设备推送令牌' }) + @IsString() + @IsNotEmpty() + newDeviceToken: string; + + @ApiProperty({ description: '应用版本', required: false }) + @IsString() + @IsOptional() + appVersion?: string; + + @ApiProperty({ description: '操作系统版本', required: false }) + @IsString() + @IsOptional() + osVersion?: string; + + @ApiProperty({ description: '设备名称', required: false }) + @IsString() + @IsOptional() + deviceName?: string; +} +``` + +### 3.2 推送消息相关DTO +```typescript +// 发送推送通知 +export class SendPushNotificationDto { + @ApiProperty({ description: '用户ID列表' }) + @IsArray() + @IsString({ each: true }) + userIds: string[]; + + @ApiProperty({ description: '推送标题' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: '推送内容' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiProperty({ description: '自定义数据', required: false }) + @IsObject() + @IsOptional() + payload?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; + + @ApiProperty({ description: '过期时间(秒)', required: false }) + @IsNumber() + @IsOptional() + expiry?: number; + + @ApiProperty({ description: '折叠ID', required: false }) + @IsString() + @IsOptional() + collapseId?: string; +} + +// 使用模板发送推送 +export class SendPushByTemplateDto { + @ApiProperty({ description: '用户ID列表' }) + @IsArray() + @IsString({ each: true }) + userIds: string[]; + + @ApiProperty({ description: '模板键' }) + @IsString() + @IsNotEmpty() + templateKey: string; + + @ApiProperty({ description: '模板数据' }) + @IsObject() + @IsNotEmpty() + data: any; + + @ApiProperty({ description: '自定义数据', required: false }) + @IsObject() + @IsOptional() + payload?: any; +} +``` + +### 3.3 推送模板相关DTO +```typescript +// 创建推送模板 +export class CreatePushTemplateDto { + @ApiProperty({ description: '模板键' }) + @IsString() + @IsNotEmpty() + templateKey: string; + + @ApiProperty({ description: '模板标题' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: '模板内容' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiProperty({ description: '负载模板', required: false }) + @IsObject() + @IsOptional() + payloadTemplate?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; +} + +// 更新推送模板 +export class UpdatePushTemplateDto { + @ApiProperty({ description: '模板标题', required: false }) + @IsString() + @IsOptional() + title?: string; + + @ApiProperty({ description: '模板内容', required: false }) + @IsString() + @IsOptional() + body?: string; + + @ApiProperty({ description: '负载模板', required: false }) + @IsObject() + @IsOptional() + payloadTemplate?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; + + @ApiProperty({ description: '是否激活', required: false }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} +``` + +### 3.4 响应DTO +```typescript +// 推送响应 +export class PushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '推送结果' }) + data: { + success: boolean; + sentCount: number; + failedCount: number; + results: PushResult[]; + }; +} + +// 批量推送响应 +export class BatchPushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '批量推送结果' }) + data: { + totalUsers: number; + totalTokens: number; + successCount: number; + failedCount: number; + results: PushResult[]; + }; +} + +// 推送结果 +export class PushResult { + @ApiProperty({ description: '用户ID' }) + userId: string; + + @ApiProperty({ description: '设备令牌' }) + deviceToken: string; + + @ApiProperty({ description: '是否成功' }) + success: boolean; + + @ApiProperty({ description: '错误信息', required: false }) + @IsString() + @IsOptional() + error?: string; + + @ApiProperty({ description: 'APNs响应', required: false }) + @IsObject() + @IsOptional() + apnsResponse?: any; +} +``` + +## 4. 控制器接口设计 + +### 4.1 PushNotificationsController +```typescript +@Controller('push-notifications') +@ApiTags('推送通知') +@UseGuards(JwtAuthGuard) +export class PushNotificationsController { + constructor(private readonly pushNotificationsService: PushNotificationsService) {} + + // 注册设备令牌 + @Post('register-token') + @ApiOperation({ summary: '注册设备推送令牌' }) + @ApiResponse({ status: 200, description: '注册成功', type: ResponseDto }) + async registerToken( + @CurrentUser() user: AccessTokenPayload, + @Body() registerTokenDto: RegisterDeviceTokenDto, + ): Promise { + return this.pushNotificationsService.registerToken(user.sub, registerTokenDto); + } + + // 更新设备令牌 + @Put('update-token') + @ApiOperation({ summary: '更新设备推送令牌' }) + @ApiResponse({ status: 200, description: '更新成功', type: ResponseDto }) + async updateToken( + @CurrentUser() user: AccessTokenPayload, + @Body() updateTokenDto: UpdateDeviceTokenDto, + ): Promise { + return this.pushNotificationsService.updateToken(user.sub, updateTokenDto); + } + + // 注销设备令牌 + @Delete('unregister-token') + @ApiOperation({ summary: '注销设备推送令牌' }) + @ApiResponse({ status: 200, description: '注销成功', type: ResponseDto }) + async unregisterToken( + @CurrentUser() user: AccessTokenPayload, + @Body() body: { deviceToken: string }, + ): Promise { + return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken); + } + + // 发送推送通知 + @Post('send') + @ApiOperation({ summary: '发送推送通知' }) + @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) + async sendNotification( + @Body() sendNotificationDto: SendPushNotificationDto, + ): Promise { + return this.pushNotificationsService.sendNotification(sendNotificationDto); + } + + // 使用模板发送推送 + @Post('send-by-template') + @ApiOperation({ summary: '使用模板发送推送' }) + @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) + async sendNotificationByTemplate( + @Body() sendByTemplateDto: SendPushByTemplateDto, + ): Promise { + return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto); + } + + // 批量发送推送 + @Post('send-batch') + @ApiOperation({ summary: '批量发送推送' }) + @ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto }) + async sendBatchNotifications( + @Body() sendBatchDto: SendPushNotificationDto, + ): Promise { + return this.pushNotificationsService.sendBatchNotifications(sendBatchDto); + } +} +``` + +### 4.2 PushTemplateController +```typescript +@Controller('push-notifications/templates') +@ApiTags('推送模板') +@UseGuards(JwtAuthGuard) +export class PushTemplateController { + constructor(private readonly pushTemplateService: PushTemplateService) {} + + // 获取所有模板 + @Get() + @ApiOperation({ summary: '获取所有推送模板' }) + @ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] }) + async getAllTemplates(): Promise { + return this.pushTemplateService.getAllTemplates(); + } + + // 获取单个模板 + @Get(':templateKey') + @ApiOperation({ summary: '获取推送模板' }) + @ApiResponse({ status: 200, description: '获取成功', type: PushTemplate }) + async getTemplate( + @Param('templateKey') templateKey: string, + ): Promise { + return this.pushTemplateService.getTemplate(templateKey); + } + + // 创建模板 + @Post() + @ApiOperation({ summary: '创建推送模板' }) + @ApiResponse({ status: 201, description: '创建成功', type: PushTemplate }) + async createTemplate( + @Body() createTemplateDto: CreatePushTemplateDto, + ): Promise { + return this.pushTemplateService.createTemplate(createTemplateDto); + } + + // 更新模板 + @Put(':id') + @ApiOperation({ summary: '更新推送模板' }) + @ApiResponse({ status: 200, description: '更新成功', type: PushTemplate }) + async updateTemplate( + @Param('id') id: string, + @Body() updateTemplateDto: UpdatePushTemplateDto, + ): Promise { + return this.pushTemplateService.updateTemplate(id, updateTemplateDto); + } + + // 删除模板 + @Delete(':id') + @ApiOperation({ summary: '删除推送模板' }) + @ApiResponse({ status: 200, description: '删除成功' }) + async deleteTemplate(@Param('id') id: string): Promise { + return this.pushTemplateService.deleteTemplate(id); + } +} +``` + +## 5. 接口使用示例 + +### 5.1 注册设备令牌 +```bash +POST /api/push-notifications/register-token +Authorization: Bearer +Content-Type: application/json + +{ + "deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7", + "deviceType": "IOS", + "appVersion": "1.0.0", + "osVersion": "iOS 15.0", + "deviceName": "iPhone 13" +} +``` + +### 5.2 发送推送通知 +```bash +POST /api/push-notifications/send +Content-Type: application/json + +{ + "userIds": ["user_123", "user_456"], + "title": "训练提醒", + "body": "您今天的普拉提训练还未完成,快来打卡吧!", + "payload": { + "type": "training_reminder", + "trainingId": "training_123" + }, + "pushType": "ALERT", + "priority": 10 +} +``` + +### 5.3 使用模板发送推送 +```bash +POST /api/push-notifications/send-by-template +Content-Type: application/json + +{ + "userIds": ["user_123"], + "templateKey": "training_reminder", + "data": { + "userName": "张三", + "trainingName": "核心力量训练" + }, + "payload": { + "type": "training_reminder", + "trainingId": "training_123" + } +} +``` + +## 6. 错误处理 + +### 6.1 错误类型定义 +```typescript +export enum PushErrorCode { + INVALID_DEVICE_TOKEN = 'INVALID_DEVICE_TOKEN', + DEVICE_TOKEN_NOT_FOR_TOPIC = 'DEVICE_TOKEN_NOT_FOR_TOPIC', + TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + TEMPLATE_NOT_FOUND = 'TEMPLATE_NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + INVALID_PAYLOAD = 'INVALID_PAYLOAD', +} + +export class PushException extends HttpException { + constructor( + errorCode: PushErrorCode, + message: string, + statusCode: HttpStatus = HttpStatus.BAD_REQUEST, + ) { + super({ code: errorCode, message }, statusCode); + } +} +``` + +### 6.2 全局异常处理 +```typescript +@Catch(PushException, Error) +export class PushExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + if (exception instanceof PushException) { + response.status(exception.getStatus()).json(exception.getResponse()); + } else { + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + code: 'INTERNAL_SERVER_ERROR', + message: '推送服务内部错误', + }); + } + } +} +``` + +## 7. 性能优化策略 + +### 7.1 连接池管理 +- 使用HTTP/2连接池提高并发性能 +- 实现连接复用和心跳保活 +- 动态调整连接池大小 + +### 7.2 批量处理优化 +- 实现批量推送减少网络请求 +- 使用队列系统处理大量推送请求 +- 实现推送优先级和限流机制 + +### 7.3 缓存策略 +- 缓存用户设备令牌减少数据库查询 +- 缓存推送模板提高渲染性能 +- 实现分布式缓存支持集群部署 \ No newline at end of file diff --git a/package.json b/package.json index 634c477..432ac86 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/sequelize": "^11.0.0", "@nestjs/swagger": "^11.1.0", + "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", "@types/uuid": "^10.0.0", "axios": "^1.10.0", @@ -105,4 +106,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/sql-scripts/push-notifications-tables-create.sql b/sql-scripts/push-notifications-tables-create.sql new file mode 100644 index 0000000..e112d91 --- /dev/null +++ b/sql-scripts/push-notifications-tables-create.sql @@ -0,0 +1,72 @@ +-- 推送令牌表 +CREATE TABLE t_user_push_tokens ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL COMMENT '用户ID', + device_token VARCHAR(255) NOT NULL COMMENT '设备推送令牌', + device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS' COMMENT '设备类型', + app_version VARCHAR(50) NULL COMMENT '应用版本', + os_version VARCHAR(50) NULL COMMENT '操作系统版本', + device_name VARCHAR(255) NULL COMMENT '设备名称', + is_active BOOLEAN DEFAULT TRUE COMMENT '是否激活', + last_used_at DATETIME NULL COMMENT '最后使用时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_device_token (device_token), + INDEX idx_user_device (user_id, device_token), + UNIQUE KEY uk_user_device_token (user_id, device_token) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户推送令牌表'; + +-- 推送消息表 +CREATE TABLE t_push_messages ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id VARCHAR(255) NOT NULL COMMENT '用户ID', + device_token VARCHAR(255) NOT NULL COMMENT '设备推送令牌', + message_type VARCHAR(50) NOT NULL COMMENT '消息类型', + title VARCHAR(255) NULL COMMENT '推送标题', + body TEXT NULL COMMENT '推送内容', + payload JSON NULL COMMENT '自定义负载数据', + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') NOT NULL DEFAULT 'ALERT' COMMENT '推送类型', + priority TINYINT NOT NULL DEFAULT 10 COMMENT '优先级', + expiry DATETIME NULL COMMENT '过期时间', + collapse_id VARCHAR(64) NULL COMMENT '折叠ID', + status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') NOT NULL DEFAULT 'PENDING' COMMENT '推送状态', + apns_response JSON NULL COMMENT 'APNs响应数据', + error_message TEXT NULL COMMENT '错误信息', + sent_at DATETIME NULL COMMENT '发送时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_message_type (message_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送消息表'; + +-- 推送模板表 +CREATE TABLE t_push_templates ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + template_key VARCHAR(100) NOT NULL UNIQUE COMMENT '模板键', + title VARCHAR(255) NOT NULL COMMENT '模板标题', + body TEXT NOT NULL COMMENT '模板内容', + payload_template JSON NULL COMMENT '负载模板', + push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') NOT NULL DEFAULT 'ALERT' COMMENT '推送类型', + priority TINYINT NOT NULL DEFAULT 10 COMMENT '优先级', + is_active BOOLEAN DEFAULT TRUE COMMENT '是否激活', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_template_key (template_key), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送模板表'; + +-- 插入默认推送模板 +INSERT INTO t_push_templates (template_key, title, body, payload_template, push_type, priority, is_active) VALUES +('training_reminder', '训练提醒', '您好{{userName}},您今天的{{trainingName}}训练还未完成,快来打卡吧!', '{"type": "training_reminder", "trainingId": "{{trainingId}}"}', 'ALERT', 10, TRUE), +('diet_record_reminder', '饮食记录提醒', '您好{{userName}},您还没有记录今天的饮食,记得及时记录哦!', '{"type": "diet_record_reminder"}', 'ALERT', 8, TRUE), +('challenge_progress', '挑战进度', '恭喜您!您已完成{{challengeName}}挑战的{{progress}}%,继续加油!', '{"type": "challenge_progress", "challengeId": "{{challengeId}}"}', 'ALERT', 9, TRUE), +('membership_expiring', '会员到期提醒', '您好{{userName}},您的会员将在{{days}}天后到期,请及时续费以免影响使用。', '{"type": "membership_expiring", "days": {{days}}}', 'ALERT', 10, TRUE), +('membership_expired', '会员已到期', '您好{{userName}},您的会员已到期,请续费以继续享受会员服务。', '{"type": "membership_expired"}', 'ALERT', 10, TRUE), +('achievement_unlocked', '成就解锁', '恭喜您解锁了"{{achievementName}}"成就!', '{"type": "achievement_unlocked", "achievementId": "{{achievementId}}"}', 'ALERT', 9, TRUE), +('workout_completed', '训练完成', '太棒了!您已完成{{workoutName}}训练,消耗了{{calories}}卡路里。', '{"type": "workout_completed", "workoutId": "{{workoutId}}", "calories": {{calories}}}', 'ALERT', 8, TRUE); \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 8e68365..d7ca9a6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { DietRecordsModule } from './diet-records/diet-records.module'; import { FoodLibraryModule } from './food-library/food-library.module'; import { WaterRecordsModule } from './water-records/water-records.module'; import { ChallengesModule } from './challenges/challenges.module'; +import { PushNotificationsModule } from './push-notifications/push-notifications.module'; @Module({ imports: [ @@ -43,6 +44,7 @@ import { ChallengesModule } from './challenges/challenges.module'; FoodLibraryModule, WaterRecordsModule, ChallengesModule, + PushNotificationsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/push-notifications/apns.provider.ts b/src/push-notifications/apns.provider.ts new file mode 100644 index 0000000..e64b006 --- /dev/null +++ b/src/push-notifications/apns.provider.ts @@ -0,0 +1,301 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as apn from '@parse/node-apn'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface'; + +@Injectable() +export class ApnsProvider implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ApnsProvider.name); + private provider: apn.Provider; + private multiProvider: apn.MultiProvider; + private config: ApnsConfig; + + constructor(private readonly configService: ConfigService) { + this.config = this.buildConfig(); + } + + async onModuleInit() { + try { + await this.initializeProvider(); + this.logger.log('APNs Provider initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize APNs Provider', error); + throw error; + } + } + + async onModuleDestroy() { + try { + this.shutdown(); + this.logger.log('APNs Provider shutdown successfully'); + } catch (error) { + this.logger.error('Error during APNs Provider shutdown', error); + } + } + + /** + * 构建APNs配置 + */ + private buildConfig(): ApnsConfig { + const keyId = this.configService.get('APNS_KEY_ID'); + const teamId = this.configService.get('APNS_TEAM_ID'); + const keyPath = this.configService.get('APNS_KEY_PATH'); + const bundleId = this.configService.get('APNS_BUNDLE_ID'); + const environment = this.configService.get('APNS_ENVIRONMENT', 'sandbox'); + const clientCount = this.configService.get('APNS_CLIENT_COUNT', 2); + + if (!keyId || !teamId || !keyPath || !bundleId) { + throw new Error('Missing required APNs configuration'); + } + + let key: string | Buffer; + try { + // 尝试读取密钥文件 + if (fs.existsSync(keyPath)) { + key = fs.readFileSync(keyPath); + } else { + // 如果是直接的内容而不是文件路径 + key = keyPath; + } + } catch (error) { + this.logger.error(`Failed to read APNs key file: ${keyPath}`, error); + throw new Error(`Invalid APNs key file: ${keyPath}`); + } + + return { + token: { + key, + keyId, + teamId, + }, + production: environment === 'production', + clientCount, + connectionRetryLimit: this.configService.get('APNS_CONNECTION_RETRY_LIMIT', 3), + heartBeat: this.configService.get('APNS_HEARTBEAT', 60000), + requestTimeout: this.configService.get('APNS_REQUEST_TIMEOUT', 5000), + }; + } + + /** + * 初始化APNs连接 + */ + private async initializeProvider(): Promise { + try { + // 创建单个Provider + this.provider = new apn.Provider(this.config); + + // 创建多Provider连接池 + this.multiProvider = new apn.MultiProvider(this.config); + + this.logger.log(`APNs Provider initialized with ${this.config.clientCount} clients`); + this.logger.log(`Environment: ${this.config.production ? 'Production' : 'Sandbox'}`); + } catch (error) { + this.logger.error('Failed to initialize APNs Provider', error); + throw error; + } + } + + /** + * 发送单个通知 + */ + async send(notification: apn.Notification, deviceTokens: string[]): Promise { + try { + this.logger.debug(`Sending notification to ${deviceTokens.length} devices`); + + const results = await this.provider.send(notification, deviceTokens); + + this.logResults(results); + + return results; + } catch (error) { + this.logger.error('Error sending notification', error); + throw error; + } + } + + /** + * 批量发送通知 + */ + async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise { + try { + this.logger.debug(`Sending ${notifications.length} notifications to ${deviceTokens.length} devices`); + + const results = await this.multiProvider.send(notifications, deviceTokens); + + this.logResults(results); + + return results; + } catch (error) { + this.logger.error('Error sending batch notifications', error); + throw error; + } + } + + /** + * 管理推送通道 + */ + async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise { + try { + this.logger.debug(`Managing channels for bundle ${bundleId} with action ${action}`); + + const results = await this.provider.manageChannels(notification, bundleId, action); + + this.logger.log(`Channel management completed: ${JSON.stringify(results)}`); + + return results; + } catch (error) { + this.logger.error('Error managing channels', error); + throw error; + } + } + + /** + * 广播实时活动通知 + */ + async broadcast(notification: apn.Notification, bundleId: string): Promise { + try { + this.logger.debug(`Broadcasting to bundle ${bundleId}`); + + const results = await this.provider.broadcast(notification, bundleId); + + this.logger.log(`Broadcast completed: ${JSON.stringify(results)}`); + + return results; + } catch (error) { + this.logger.error('Error broadcasting', error); + throw error; + } + } + + /** + * 创建标准通知 + */ + createNotification(options: { + title?: string; + body?: string; + payload?: any; + pushType?: string; + priority?: number; + expiry?: number; + collapseId?: string; + topic?: string; + sound?: string; + badge?: number; + mutableContent?: boolean; + contentAvailable?: boolean; + }): apn.Notification { + const notification = new apn.Notification(); + + // 设置基本内容 + if (options.title) { + notification.title = options.title; + } + + if (options.body) { + notification.body = options.body; + } + + // 设置自定义负载 + if (options.payload) { + notification.payload = options.payload; + } + + // 设置推送类型 + if (options.pushType) { + notification.pushType = options.pushType; + } + + // 设置优先级 + if (options.priority) { + notification.priority = options.priority; + } + + // 设置过期时间 + if (options.expiry) { + notification.expiry = options.expiry; + } + + // 设置折叠ID + if (options.collapseId) { + notification.collapseId = options.collapseId; + } + + // 设置主题 + if (options.topic) { + notification.topic = options.topic; + } + + // 设置声音 + if (options.sound) { + notification.sound = options.sound; + } + + // 设置徽章 + if (options.badge) { + notification.badge = options.badge; + } + + // 设置可变内容 + if (options.mutableContent) { + notification.mutableContent = 1; + } + + // 设置静默推送 + if (options.contentAvailable) { + notification.contentAvailable = 1; + } + + return notification; + } + + /** + * 记录推送结果 + */ + private logResults(results: apn.Results): void { + const { sent, failed } = results; + + this.logger.log(`Push results: ${sent.length} sent, ${failed.length} failed`); + + if (failed.length > 0) { + failed.forEach((failure) => { + if (failure.error) { + this.logger.error(`Push error for device ${failure.device}: ${failure.error.message}`); + } else { + this.logger.warn(`Push rejected for device ${failure.device}: ${failure.status} - ${JSON.stringify(failure.response)}`); + } + }); + } + } + + /** + * 关闭连接 + */ + shutdown(): void { + try { + if (this.provider) { + this.provider.shutdown(); + } + + if (this.multiProvider) { + this.multiProvider.shutdown(); + } + + this.logger.log('APNs Provider connections closed'); + } catch (error) { + this.logger.error('Error closing APNs Provider connections', error); + } + } + + /** + * 获取Provider状态 + */ + getStatus(): { connected: boolean; clientCount: number; environment: string } { + return { + connected: !!(this.provider || this.multiProvider), + clientCount: this.config.clientCount || 1, + environment: this.config.production ? 'production' : 'sandbox', + }; + } +} \ No newline at end of file diff --git a/src/push-notifications/dto/create-push-template.dto.ts b/src/push-notifications/dto/create-push-template.dto.ts new file mode 100644 index 0000000..c59c22d --- /dev/null +++ b/src/push-notifications/dto/create-push-template.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; +import { PushType } from '../enums/push-type.enum'; + +export class CreatePushTemplateDto { + @ApiProperty({ description: '模板键' }) + @IsString() + @IsNotEmpty() + templateKey: string; + + @ApiProperty({ description: '模板标题' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: '模板内容' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiProperty({ description: '负载模板', required: false }) + @IsObject() + @IsOptional() + payloadTemplate?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; +} \ No newline at end of file diff --git a/src/push-notifications/dto/push-response.dto.ts b/src/push-notifications/dto/push-response.dto.ts new file mode 100644 index 0000000..daf15d6 --- /dev/null +++ b/src/push-notifications/dto/push-response.dto.ts @@ -0,0 +1,93 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ResponseCode } from '../../base.dto'; + +export class PushResult { + @ApiProperty({ description: '用户ID' }) + userId: string; + + @ApiProperty({ description: '设备令牌' }) + deviceToken: string; + + @ApiProperty({ description: '是否成功' }) + success: boolean; + + @ApiProperty({ description: '错误信息', required: false }) + error?: string; + + @ApiProperty({ description: 'APNs响应', required: false }) + apnsResponse?: any; +} + +export class PushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '推送结果' }) + data: { + success: boolean; + sentCount: number; + failedCount: number; + results: PushResult[]; + }; +} + +export class BatchPushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '批量推送结果' }) + data: { + totalUsers: number; + totalTokens: number; + successCount: number; + failedCount: number; + results: PushResult[]; + }; +} + +export class RegisterTokenResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '注册结果' }) + data: { + success: boolean; + tokenId: string; + }; +} + +export class UpdateTokenResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '更新结果' }) + data: { + success: boolean; + tokenId: string; + }; +} + +export class UnregisterTokenResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '注销结果' }) + data: { + success: boolean; + }; +} \ No newline at end of file diff --git a/src/push-notifications/dto/register-device-token.dto.ts b/src/push-notifications/dto/register-device-token.dto.ts new file mode 100644 index 0000000..85b7d47 --- /dev/null +++ b/src/push-notifications/dto/register-device-token.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator'; +import { DeviceType } from '../enums/device-type.enum'; + +export class RegisterDeviceTokenDto { + @ApiProperty({ description: '设备推送令牌' }) + @IsString() + @IsNotEmpty() + deviceToken: string; + + @ApiProperty({ description: '设备类型', enum: DeviceType }) + @IsEnum(DeviceType) + deviceType: DeviceType; + + @ApiProperty({ description: '应用版本', required: false }) + @IsString() + @IsOptional() + appVersion?: string; + + @ApiProperty({ description: '操作系统版本', required: false }) + @IsString() + @IsOptional() + osVersion?: string; + + @ApiProperty({ description: '设备名称', required: false }) + @IsString() + @IsOptional() + deviceName?: string; +} \ No newline at end of file diff --git a/src/push-notifications/dto/send-push-by-template.dto.ts b/src/push-notifications/dto/send-push-by-template.dto.ts new file mode 100644 index 0000000..93cde50 --- /dev/null +++ b/src/push-notifications/dto/send-push-by-template.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional } from 'class-validator'; + +export class SendPushByTemplateDto { + @ApiProperty({ description: '用户ID列表' }) + @IsArray() + @IsString({ each: true }) + userIds: string[]; + + @ApiProperty({ description: '模板键' }) + @IsString() + @IsNotEmpty() + templateKey: string; + + @ApiProperty({ description: '模板数据' }) + @IsObject() + @IsNotEmpty() + data: any; + + @ApiProperty({ description: '自定义数据', required: false }) + @IsObject() + @IsOptional() + payload?: any; + + @ApiProperty({ description: '折叠ID', required: false }) + @IsString() + @IsOptional() + collapseId?: string; + + @ApiProperty({ description: '声音', required: false }) + @IsString() + @IsOptional() + sound?: string; + + @ApiProperty({ description: '徽章数', required: false }) + @IsOptional() + badge?: number; +} \ No newline at end of file diff --git a/src/push-notifications/dto/send-push-notification.dto.ts b/src/push-notifications/dto/send-push-notification.dto.ts new file mode 100644 index 0000000..39feaae --- /dev/null +++ b/src/push-notifications/dto/send-push-notification.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; +import { PushType } from '../enums/push-type.enum'; + +export class SendPushNotificationDto { + @ApiProperty({ description: '用户ID列表' }) + @IsArray() + @IsString({ each: true }) + userIds: string[]; + + @ApiProperty({ description: '推送标题' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: '推送内容' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiProperty({ description: '自定义数据', required: false }) + @IsObject() + @IsOptional() + payload?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; + + @ApiProperty({ description: '过期时间(秒)', required: false }) + @IsNumber() + @IsOptional() + expiry?: number; + + @ApiProperty({ description: '折叠ID', required: false }) + @IsString() + @IsOptional() + collapseId?: string; + + @ApiProperty({ description: '声音', required: false }) + @IsString() + @IsOptional() + sound?: string; + + @ApiProperty({ description: '徽章数', required: false }) + @IsNumber() + @IsOptional() + badge?: number; + + @ApiProperty({ description: '是否可变内容', required: false }) + @IsOptional() + mutableContent?: boolean; + + @ApiProperty({ description: '是否静默推送', required: false }) + @IsOptional() + contentAvailable?: boolean; +} \ No newline at end of file diff --git a/src/push-notifications/dto/update-device-token.dto.ts b/src/push-notifications/dto/update-device-token.dto.ts new file mode 100644 index 0000000..2cdab3f --- /dev/null +++ b/src/push-notifications/dto/update-device-token.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class UpdateDeviceTokenDto { + @ApiProperty({ description: '当前设备推送令牌' }) + @IsString() + @IsNotEmpty() + currentDeviceToken: string; + + @ApiProperty({ description: '新的设备推送令牌' }) + @IsString() + @IsNotEmpty() + newDeviceToken: string; + + @ApiProperty({ description: '应用版本', required: false }) + @IsString() + @IsOptional() + appVersion?: string; + + @ApiProperty({ description: '操作系统版本', required: false }) + @IsString() + @IsOptional() + osVersion?: string; + + @ApiProperty({ description: '设备名称', required: false }) + @IsString() + @IsOptional() + deviceName?: string; +} \ No newline at end of file diff --git a/src/push-notifications/dto/update-push-template.dto.ts b/src/push-notifications/dto/update-push-template.dto.ts new file mode 100644 index 0000000..7b7ba3d --- /dev/null +++ b/src/push-notifications/dto/update-push-template.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator'; +import { PushType } from '../enums/push-type.enum'; + +export class UpdatePushTemplateDto { + @ApiProperty({ description: '模板标题', required: false }) + @IsString() + @IsOptional() + title?: string; + + @ApiProperty({ description: '模板内容', required: false }) + @IsString() + @IsOptional() + body?: string; + + @ApiProperty({ description: '负载模板', required: false }) + @IsObject() + @IsOptional() + payloadTemplate?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; + + @ApiProperty({ description: '是否激活', required: false }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} \ No newline at end of file diff --git a/src/push-notifications/enums/device-type.enum.ts b/src/push-notifications/enums/device-type.enum.ts new file mode 100644 index 0000000..534e7dc --- /dev/null +++ b/src/push-notifications/enums/device-type.enum.ts @@ -0,0 +1,4 @@ +export enum DeviceType { + IOS = 'IOS', + ANDROID = 'ANDROID', +} \ No newline at end of file diff --git a/src/push-notifications/enums/push-message-status.enum.ts b/src/push-notifications/enums/push-message-status.enum.ts new file mode 100644 index 0000000..ce0c1d6 --- /dev/null +++ b/src/push-notifications/enums/push-message-status.enum.ts @@ -0,0 +1,6 @@ +export enum PushMessageStatus { + PENDING = 'PENDING', + SENT = 'SENT', + FAILED = 'FAILED', + EXPIRED = 'EXPIRED', +} \ No newline at end of file diff --git a/src/push-notifications/enums/push-type.enum.ts b/src/push-notifications/enums/push-type.enum.ts new file mode 100644 index 0000000..d3b00a5 --- /dev/null +++ b/src/push-notifications/enums/push-type.enum.ts @@ -0,0 +1,6 @@ +export enum PushType { + ALERT = 'ALERT', + BACKGROUND = 'BACKGROUND', + VOIP = 'VOIP', + LIVEACTIVITY = 'LIVEACTIVITY', +} \ No newline at end of file diff --git a/src/push-notifications/interfaces/apns-config.interface.ts b/src/push-notifications/interfaces/apns-config.interface.ts new file mode 100644 index 0000000..a4dcbed --- /dev/null +++ b/src/push-notifications/interfaces/apns-config.interface.ts @@ -0,0 +1,25 @@ +export interface ApnsConfig { + token: { + key: string | Buffer; + keyId: string; + teamId: string; + }; + production: boolean; + clientCount?: number; + proxy?: { + host: string; + port: number; + }; + connectionRetryLimit?: number; + heartBeat?: number; + requestTimeout?: number; +} + +export interface ApnsNotificationOptions { + topic: string; + id?: string; + collapseId?: string; + priority?: number; + pushType?: string; + expiry?: number; +} \ No newline at end of file diff --git a/src/push-notifications/interfaces/push-notification.interface.ts b/src/push-notifications/interfaces/push-notification.interface.ts new file mode 100644 index 0000000..f3ea3ac --- /dev/null +++ b/src/push-notifications/interfaces/push-notification.interface.ts @@ -0,0 +1,65 @@ +import { PushType } from '../enums/push-type.enum'; + +export interface PushNotificationRequest { + userIds: string[]; + title: string; + body: string; + payload?: any; + pushType?: PushType; + priority?: number; + expiry?: number; + collapseId?: string; +} + +export interface PushNotificationByTemplateRequest { + userIds: string[]; + templateKey: string; + data: any; + payload?: any; +} + +export interface PushResult { + userId: string; + deviceToken: string; + success: boolean; + error?: string; + apnsResponse?: any; +} + +export interface BatchPushResult { + totalUsers: number; + totalTokens: number; + successCount: number; + failedCount: number; + results: PushResult[]; +} + +export interface RenderedTemplate { + title: string; + body: string; + payload?: any; + pushType: PushType; + priority: number; +} + +export interface PushStats { + totalSent: number; + totalFailed: number; + successRate: number; + averageDeliveryTime: number; + errorBreakdown: Record; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + startDate?: Date; + endDate?: Date; + status?: string; + messageType?: string; +} + +export interface TimeRange { + startDate: Date; + endDate: Date; +} \ No newline at end of file diff --git a/src/push-notifications/models/push-message.model.ts b/src/push-notifications/models/push-message.model.ts new file mode 100644 index 0000000..69ffd98 --- /dev/null +++ b/src/push-notifications/models/push-message.model.ts @@ -0,0 +1,147 @@ +import { Column, Model, Table, DataType, Index } from 'sequelize-typescript'; +import { PushType } from '../enums/push-type.enum'; +import { PushMessageStatus } from '../enums/push-message-status.enum'; + +@Table({ + tableName: 't_push_messages', + underscored: true, + indexes: [ + { + name: 'idx_user_id', + fields: ['user_id'], + }, + { + name: 'idx_status', + fields: ['status'], + }, + { + name: 'idx_created_at', + fields: ['created_at'], + }, + { + name: 'idx_message_type', + fields: ['message_type'], + }, + ], +}) +export class PushMessage extends Model { + @Column({ + type: DataType.UUID, + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '设备推送令牌', + }) + declare deviceToken: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '消息类型', + }) + declare messageType: string; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '推送标题', + }) + declare title?: string; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '推送内容', + }) + declare body?: string; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '自定义负载数据', + }) + declare payload?: any; + + @Column({ + type: DataType.ENUM(...Object.values(PushType)), + allowNull: false, + defaultValue: PushType.ALERT, + comment: '推送类型', + }) + declare pushType: PushType; + + @Column({ + type: DataType.TINYINT, + allowNull: false, + defaultValue: 10, + comment: '优先级', + }) + declare priority: number; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '过期时间', + }) + declare expiry?: Date; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '折叠ID', + }) + declare collapseId?: string; + + @Column({ + type: DataType.ENUM(...Object.values(PushMessageStatus)), + allowNull: false, + defaultValue: PushMessageStatus.PENDING, + comment: '推送状态', + }) + declare status: PushMessageStatus; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: 'APNs响应数据', + }) + declare apnsResponse?: any; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '错误信息', + }) + declare errorMessage?: string; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '发送时间', + }) + declare sentAt?: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/src/push-notifications/models/push-template.model.ts b/src/push-notifications/models/push-template.model.ts new file mode 100644 index 0000000..e935bae --- /dev/null +++ b/src/push-notifications/models/push-template.model.ts @@ -0,0 +1,95 @@ +import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript'; +import { PushType } from '../enums/push-type.enum'; + +@Table({ + tableName: 't_push_templates', + underscored: true, + indexes: [ + { + name: 'idx_template_key', + fields: ['template_key'], + unique: true, + }, + { + name: 'idx_is_active', + fields: ['is_active'], + }, + ], +}) +export class PushTemplate extends Model { + @Column({ + type: DataType.UUID, + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + unique: true, + field: 'template_key', + comment: '模板键', + }) + declare templateKey: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '模板标题', + }) + declare title: string; + + @Column({ + type: DataType.TEXT, + allowNull: false, + comment: '模板内容', + }) + declare body: string; + + @Column({ + type: DataType.JSON, + allowNull: true, + field: 'payload_template', + comment: '负载模板', + }) + declare payloadTemplate?: any; + + @Column({ + type: DataType.ENUM(...Object.values(PushType)), + allowNull: false, + defaultValue: PushType.ALERT, + field: 'push_type', + comment: '推送类型', + }) + declare pushType: PushType; + + @Column({ + type: DataType.TINYINT, + allowNull: false, + defaultValue: 10, + comment: '优先级', + }) + declare priority: number; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'is_active', + comment: '是否激活', + }) + declare isActive: boolean; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/src/push-notifications/models/user-push-token.model.ts b/src/push-notifications/models/user-push-token.model.ts new file mode 100644 index 0000000..2eed7a2 --- /dev/null +++ b/src/push-notifications/models/user-push-token.model.ts @@ -0,0 +1,100 @@ +import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript'; +import { DeviceType } from '../enums/device-type.enum'; + +@Table({ + tableName: 't_user_push_tokens', + underscored: true, + indexes: [ + { + name: 'idx_user_id', + fields: ['user_id'], + }, + { + name: 'idx_device_token', + fields: ['device_token'], + }, + { + name: 'idx_user_device', + fields: ['user_id', 'device_token'], + unique: true, + }, + ], +}) +export class UserPushToken extends Model { + @Column({ + type: DataType.UUID, + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '设备推送令牌', + }) + declare deviceToken: string; + + @Column({ + type: DataType.ENUM(...Object.values(DeviceType)), + allowNull: false, + defaultValue: DeviceType.IOS, + comment: '设备类型', + }) + declare deviceType: DeviceType; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '应用版本', + }) + declare appVersion?: string; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '操作系统版本', + }) + declare osVersion?: string; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '设备名称', + }) + declare deviceName?: string; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否激活', + }) + declare isActive: boolean; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '最后使用时间', + }) + declare lastUsedAt?: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/src/push-notifications/push-message.service.ts b/src/push-notifications/push-message.service.ts new file mode 100644 index 0000000..1b07d01 --- /dev/null +++ b/src/push-notifications/push-message.service.ts @@ -0,0 +1,387 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op } from 'sequelize'; +import { PushMessage } from './models/push-message.model'; +import { PushMessageStatus } from './enums/push-message-status.enum'; +import { PushStats, QueryOptions, TimeRange } from './interfaces/push-notification.interface'; + +export interface CreatePushMessageDto { + userId: string; + deviceToken: string; + messageType: string; + title?: string; + body?: string; + payload?: any; + pushType?: string; + priority?: number; + expiry?: Date; + collapseId?: string; +} + +@Injectable() +export class PushMessageService { + private readonly logger = new Logger(PushMessageService.name); + + constructor( + @InjectModel(PushMessage) + private readonly messageModel: typeof PushMessage, + ) { } + + /** + * 创建推送消息记录 + */ + async createMessage(messageData: CreatePushMessageDto): Promise { + try { + this.logger.log(`Creating push message for user ${messageData.userId}`); + + const message = await this.messageModel.create({ + userId: messageData.userId, + deviceToken: messageData.deviceToken, + messageType: messageData.messageType, + title: messageData.title, + body: messageData.body, + payload: messageData.payload, + pushType: messageData.pushType, + priority: messageData.priority || 10, + expiry: messageData.expiry, + collapseId: messageData.collapseId, + status: PushMessageStatus.PENDING, + }); + + this.logger.log(`Successfully created push message with ID: ${message.id}`); + return message; + } catch (error) { + this.logger.error(`Failed to create push message: ${error.message}`, error); + throw error; + } + } + + /** + * 更新消息状态 + */ + async updateMessageStatus(id: string, status: PushMessageStatus, response?: any, errorMessage?: string): Promise { + try { + this.logger.log(`Updating push message status to ${status} for ID: ${id}`); + + const updateData: any = { + status, + }; + + if (status === PushMessageStatus.SENT) { + updateData.sentAt = new Date(); + } + + if (response) { + updateData.apnsResponse = response; + } + + if (errorMessage) { + updateData.errorMessage = errorMessage; + } + + await this.messageModel.update(updateData, { + where: { + id, + }, + }); + + this.logger.log(`Successfully updated push message status for ID: ${id}`); + } catch (error) { + this.logger.error(`Failed to update push message status: ${error.message}`, error); + throw error; + } + } + + /** + * 批量更新消息状态 + */ + async updateMessageStatusBatch(ids: string[], status: PushMessageStatus, response?: any, errorMessage?: string): Promise { + try { + this.logger.log(`Batch updating ${ids.length} push messages to status: ${status}`); + + const updateData: any = { + status, + }; + + if (status === PushMessageStatus.SENT) { + updateData.sentAt = new Date(); + } + + if (response) { + updateData.apnsResponse = response; + } + + if (errorMessage) { + updateData.errorMessage = errorMessage; + } + + await this.messageModel.update(updateData, { + where: { + id: { + [Op.in]: ids, + }, + }, + }); + + this.logger.log(`Successfully batch updated ${ids.length} push messages`); + } catch (error) { + this.logger.error(`Failed to batch update push messages: ${error.message}`, error); + throw error; + } + } + + /** + * 获取消息历史 + */ + async getMessageHistory(userId: string, options: QueryOptions = {}): Promise { + try { + const whereClause: any = { + userId, + }; + + if (options.status) { + whereClause.status = options.status; + } + + if (options.messageType) { + whereClause.messageType = options.messageType; + } + + if (options.startDate || options.endDate) { + whereClause.createdAt = {}; + if (options.startDate) { + whereClause.createdAt[Op.gte] = options.startDate; + } + if (options.endDate) { + whereClause.createdAt[Op.lte] = options.endDate; + } + } + + const messages = await this.messageModel.findAll({ + where: whereClause, + order: [['createdAt', 'DESC']], + limit: options.limit, + offset: options.offset, + }); + + this.logger.log(`Found ${messages.length} messages for user ${userId}`); + return messages; + } catch (error) { + this.logger.error(`Failed to get message history: ${error.message}`, error); + throw error; + } + } + + /** + * 获取消息统计 + */ + async getMessageStats(userId?: string, timeRange?: TimeRange): Promise { + try { + const whereClause: any = {}; + + if (userId) { + whereClause.userId = userId; + } + + if (timeRange) { + whereClause.createdAt = { + [Op.between]: [timeRange.startDate, timeRange.endDate], + }; + } + + const totalSent = await this.messageModel.count({ + where: { + ...whereClause, + status: PushMessageStatus.SENT, + }, + }); + + const totalFailed = await this.messageModel.count({ + where: { + ...whereClause, + status: PushMessageStatus.FAILED, + }, + }); + + const total = totalSent + totalFailed; + const successRate = total > 0 ? (totalSent / total) * 100 : 0; + + // 获取错误分布 + const errorMessages = await this.messageModel.findAll({ + where: { + ...whereClause, + status: PushMessageStatus.FAILED, + errorMessage: { + [Op.not]: null, + }, + }, + attributes: ['errorMessage'], + }); + + const errorBreakdown: Record = {}; + errorMessages.forEach((message) => { + if (message.errorMessage) { + const errorKey = this.categorizeError(message.errorMessage); + errorBreakdown[errorKey] = (errorBreakdown[errorKey] || 0) + 1; + } + }); + + // 计算平均发送时间(简化版本) + const averageDeliveryTime = await this.calculateAverageDeliveryTime(whereClause); + + const stats: PushStats = { + totalSent, + totalFailed, + successRate, + averageDeliveryTime, + errorBreakdown, + }; + + this.logger.log(`Generated message stats: ${JSON.stringify(stats)}`); + return stats; + } catch (error) { + this.logger.error(`Failed to get message stats: ${error.message}`, error); + throw error; + } + } + + /** + * 清理过期消息 + */ + async cleanupExpiredMessages(): Promise { + try { + this.logger.log('Starting cleanup of expired messages'); + + // 清理30天前的消息 + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const result = await this.messageModel.destroy({ + where: { + createdAt: { + [Op.lt]: thirtyDaysAgo, + }, + }, + }); + + this.logger.log(`Cleaned up ${result} expired messages`); + return result; + } catch (error) { + this.logger.error(`Failed to cleanup expired messages: ${error.message}`, error); + throw error; + } + } + + /** + * 获取待发送的消息 + */ + async getPendingMessages(limit: number = 100): Promise { + try { + const messages = await this.messageModel.findAll({ + where: { + status: PushMessageStatus.PENDING, + [Op.or]: [ + { + expiry: { + [Op.or]: [ + { [Op.is]: null }, + { [Op.gt]: new Date() }, + ], + }, + }, + ], + }, + order: [['priority', 'DESC'], ['createdAt', 'ASC']], + limit, + }); + + return messages; + } catch (error) { + this.logger.error(`Failed to get pending messages: ${error.message}`, error); + throw error; + } + } + + /** + * 根据设备令牌获取待发送消息 + */ + async getPendingMessagesByDeviceToken(deviceToken: string): Promise { + try { + const messages = await this.messageModel.findAll({ + where: { + deviceToken, + status: PushMessageStatus.PENDING, + [Op.or]: [ + { + expiry: { + [Op.or]: [ + { [Op.is]: null }, + { [Op.gt]: new Date() }, + ], + }, + }, + ], + }, + order: [['priority', 'DESC'], ['createdAt', 'ASC']], + }); + + return messages; + } catch (error) { + this.logger.error(`Failed to get pending messages by device token: ${error.message}`, error); + throw error; + } + } + + /** + * 分类错误信息 + */ + private categorizeError(errorMessage: string): string { + if (errorMessage.includes('Unregistered') || errorMessage.includes('BadDeviceToken')) { + return 'Invalid Token'; + } else if (errorMessage.includes('DeviceTokenNotForTopic')) { + return 'Topic Mismatch'; + } else if (errorMessage.includes('TooManyRequests')) { + return 'Rate Limit'; + } else if (errorMessage.includes('InternalServerError')) { + return 'Server Error'; + } else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { + return 'Timeout'; + } else { + return 'Other'; + } + } + + /** + * 计算平均发送时间 + */ + private async calculateAverageDeliveryTime(whereClause: any): Promise { + try { + const messages = await this.messageModel.findAll({ + where: { + ...whereClause, + status: PushMessageStatus.SENT, + sentAt: { + [Op.not]: null, + }, + }, + attributes: ['createdAt', 'sentAt'], + }); + + if (messages.length === 0) { + return 0; + } + + const totalDeliveryTime = messages.reduce((sum, message) => { + if (message.sentAt && message.createdAt) { + return sum + (message.sentAt.getTime() - message.createdAt.getTime()); + } + return sum; + }, 0); + + return totalDeliveryTime / messages.length; + } catch (error) { + this.logger.error(`Failed to calculate average delivery time: ${error.message}`, error); + return 0; + } + } +} \ No newline at end of file diff --git a/src/push-notifications/push-notifications.controller.ts b/src/push-notifications/push-notifications.controller.ts new file mode 100644 index 0000000..d45eddf --- /dev/null +++ b/src/push-notifications/push-notifications.controller.ts @@ -0,0 +1,85 @@ +import { Controller, Post, Put, Delete, Body, Param, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { PushNotificationsService } from './push-notifications.service'; +import { RegisterDeviceTokenDto } from './dto/register-device-token.dto'; +import { UpdateDeviceTokenDto } from './dto/update-device-token.dto'; +import { SendPushNotificationDto } from './dto/send-push-notification.dto'; +import { SendPushByTemplateDto } from './dto/send-push-by-template.dto'; +import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { Public } from '../common/decorators/public.decorator'; + +@ApiTags('推送通知') +@Controller('push-notifications') +@UseGuards(JwtAuthGuard) +export class PushNotificationsController { + constructor(private readonly pushNotificationsService: PushNotificationsService) { } + + @Post('register-token') + @ApiOperation({ summary: '注册设备推送令牌' }) + @ApiResponse({ status: 200, description: '注册成功', type: RegisterTokenResponseDto }) + async registerToken( + @CurrentUser() user: AccessTokenPayload, + @Body() registerTokenDto: RegisterDeviceTokenDto, + ): Promise { + return this.pushNotificationsService.registerToken(user.sub, registerTokenDto); + } + + @Put('update-token') + @ApiOperation({ summary: '更新设备推送令牌' }) + @ApiResponse({ status: 200, description: '更新成功', type: UpdateTokenResponseDto }) + async updateToken( + @CurrentUser() user: AccessTokenPayload, + @Body() updateTokenDto: UpdateDeviceTokenDto, + ): Promise { + return this.pushNotificationsService.updateToken(user.sub, updateTokenDto); + } + + @Delete('unregister-token') + @ApiOperation({ summary: '注销设备推送令牌' }) + @ApiResponse({ status: 200, description: '注销成功', type: UnregisterTokenResponseDto }) + async unregisterToken( + @CurrentUser() user: AccessTokenPayload, + @Body() body: { deviceToken: string }, + ): Promise { + return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken); + } + + @Post('send') + @ApiOperation({ summary: '发送推送通知' }) + @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) + async sendNotification( + @Body() sendNotificationDto: SendPushNotificationDto, + ): Promise { + return this.pushNotificationsService.sendNotification(sendNotificationDto); + } + + @Post('send-by-template') + @ApiOperation({ summary: '使用模板发送推送' }) + @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) + async sendNotificationByTemplate( + @Body() sendByTemplateDto: SendPushByTemplateDto, + ): Promise { + return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto); + } + + @Post('send-batch') + @ApiOperation({ summary: '批量发送推送' }) + @ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto }) + async sendBatchNotifications( + @Body() sendBatchDto: SendPushNotificationDto, + ): Promise { + return this.pushNotificationsService.sendBatchNotifications(sendBatchDto); + } + + @Post('send-silent') + @ApiOperation({ summary: '发送静默推送' }) + @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) + async sendSilentNotification( + @Body() body: { userId: string; payload: any }, + ): Promise { + return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload); + } +} \ No newline at end of file diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts new file mode 100644 index 0000000..a3194f2 --- /dev/null +++ b/src/push-notifications/push-notifications.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { PushNotificationsController } from './push-notifications.controller'; +import { PushTemplateController } from './push-template.controller'; +import { PushNotificationsService } from './push-notifications.service'; +import { ApnsProvider } from './apns.provider'; +import { PushTokenService } from './push-token.service'; +import { PushTemplateService } from './push-template.service'; +import { PushMessageService } from './push-message.service'; +import { UserPushToken } from './models/user-push-token.model'; +import { PushMessage } from './models/push-message.model'; +import { PushTemplate } from './models/push-template.model'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from '../database/database.module'; + +@Module({ + imports: [ + ConfigModule, + DatabaseModule, + SequelizeModule.forFeature([ + UserPushToken, + PushMessage, + PushTemplate, + ]), + ], + controllers: [ + PushNotificationsController, + PushTemplateController, + ], + providers: [ + ApnsProvider, + PushNotificationsService, + PushTokenService, + PushTemplateService, + PushMessageService, + ], + exports: [ + ApnsProvider, + PushNotificationsService, + PushTokenService, + PushTemplateService, + PushMessageService, + ], +}) +export class PushNotificationsModule { } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.service.ts b/src/push-notifications/push-notifications.service.ts new file mode 100644 index 0000000..f9f2b5a --- /dev/null +++ b/src/push-notifications/push-notifications.service.ts @@ -0,0 +1,502 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApnsProvider } from './apns.provider'; +import { PushTokenService } from './push-token.service'; +import { PushTemplateService } from './push-template.service'; +import { PushMessageService, CreatePushMessageDto } from './push-message.service'; +import { SendPushNotificationDto } from './dto/send-push-notification.dto'; +import { SendPushByTemplateDto } from './dto/send-push-by-template.dto'; +import { PushResult, BatchPushResult } from './interfaces/push-notification.interface'; +import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto'; +import { ResponseCode } from '../base.dto'; +import { PushType } from './enums/push-type.enum'; +import { PushMessageStatus } from './enums/push-message-status.enum'; + +@Injectable() +export class PushNotificationsService { + private readonly logger = new Logger(PushNotificationsService.name); + private readonly bundleId: string; + + constructor( + private readonly apnsProvider: ApnsProvider, + private readonly pushTokenService: PushTokenService, + private readonly pushTemplateService: PushTemplateService, + private readonly pushMessageService: PushMessageService, + private readonly configService: ConfigService, + ) { + this.bundleId = this.configService.get('APNS_BUNDLE_ID') || ''; + } + + /** + * 发送单个推送通知 + */ + async sendNotification(notificationData: SendPushNotificationDto): Promise { + try { + this.logger.log(`Sending push notification to ${notificationData.userIds.length} users`); + + const results: PushResult[] = []; + let sentCount = 0; + let failedCount = 0; + + // 获取所有用户的设备令牌 + const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds); + + // 为每个用户创建消息记录并发送推送 + for (const userId of notificationData.userIds) { + const deviceTokens = userTokensMap.get(userId) || []; + + if (deviceTokens.length === 0) { + this.logger.warn(`No active device tokens found for user ${userId}`); + results.push({ + userId, + deviceToken: '', + success: false, + error: 'No active device tokens found', + }); + failedCount++; + continue; + } + + // 为每个设备令牌创建消息记录 + for (const deviceToken of deviceTokens) { + try { + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId, + deviceToken, + messageType: 'manual', + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined, + collapseId: notificationData.collapseId, + }; + + const message = await this.pushMessageService.createMessage(messageData); + + // 创建APNs通知 + const apnsNotification = this.apnsProvider.createNotification({ + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry, + collapseId: notificationData.collapseId, + topic: this.bundleId, + sound: notificationData.sound, + badge: notificationData.badge, + mutableContent: notificationData.mutableContent, + contentAvailable: notificationData.contentAvailable, + }); + + // 发送推送 + const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]); + + // 处理结果 + if (apnsResults.sent.length > 0) { + await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults); + await this.pushTokenService.updateLastUsedTime(deviceToken); + results.push({ + userId, + deviceToken, + success: true, + apnsResponse: apnsResults, + }); + sentCount++; + } else { + const failure = apnsResults.failed[0]; + const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; + + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + failure.response, + errorMessage + ); + + // 如果是无效令牌,停用该令牌 + if (failure.status === '410' || failure.response?.reason === 'Unregistered') { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } + + results.push({ + userId, + deviceToken, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to send push to user ${userId}, device ${deviceToken}: ${error.message}`, error); + results.push({ + userId, + deviceToken, + success: false, + error: error.message, + }); + failedCount++; + } + } + } + + const success = failedCount === 0; + + return { + code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR, + message: success ? '推送发送成功' : '部分推送发送失败', + data: { + success, + sentCount, + failedCount, + results, + }, + }; + } catch (error) { + this.logger.error(`Failed to send push notification: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `推送发送失败: ${error.message}`, + data: { + success: false, + sentCount: 0, + failedCount: notificationData.userIds.length, + results: [], + }, + }; + } + } + + /** + * 使用模板发送推送通知 + */ + async sendNotificationByTemplate(templateData: SendPushByTemplateDto): Promise { + try { + this.logger.log(`Sending push notification using template: ${templateData.templateKey}`); + + // 渲染模板 + const renderedTemplate = await this.pushTemplateService.renderTemplate( + templateData.templateKey, + templateData.data + ); + + // 构建推送数据 + const notificationData: SendPushNotificationDto = { + userIds: templateData.userIds, + title: renderedTemplate.title, + body: renderedTemplate.body, + payload: { ...renderedTemplate.payload, ...templateData.payload }, + pushType: renderedTemplate.pushType, + priority: renderedTemplate.priority, + collapseId: templateData.collapseId, + sound: templateData.sound, + badge: templateData.badge, + }; + + // 发送推送 + return this.sendNotification(notificationData); + } catch (error) { + this.logger.error(`Failed to send push notification by template: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `模板推送发送失败: ${error.message}`, + data: { + success: false, + sentCount: 0, + failedCount: templateData.userIds.length, + results: [], + }, + }; + } + } + + /** + * 批量发送推送通知 + */ + async sendBatchNotifications(notificationData: SendPushNotificationDto): Promise { + try { + this.logger.log(`Sending batch push notification to ${notificationData.userIds.length} users`); + + const results: PushResult[] = []; + let totalUsers = notificationData.userIds.length; + let totalTokens = 0; + let successCount = 0; + let failedCount = 0; + + // 获取所有用户的设备令牌 + const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds); + + // 统计总令牌数 + for (const tokens of userTokensMap.values()) { + totalTokens += tokens.length; + } + + // 创建APNs通知 + const apnsNotification = this.apnsProvider.createNotification({ + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry, + collapseId: notificationData.collapseId, + topic: this.bundleId, + sound: notificationData.sound, + badge: notificationData.badge, + mutableContent: notificationData.mutableContent, + contentAvailable: notificationData.contentAvailable, + }); + + // 批量发送推送 + const allDeviceTokens = Array.from(userTokensMap.values()).flat(); + if (allDeviceTokens.length === 0) { + return { + code: ResponseCode.ERROR, + message: '没有找到有效的设备令牌', + data: { + totalUsers, + totalTokens: 0, + successCount: 0, + failedCount: totalUsers, + results: [], + }, + }; + } + + const apnsResults = await this.apnsProvider.send(apnsNotification, allDeviceTokens); + + // 处理结果并创建消息记录 + for (const [userId, deviceTokens] of userTokensMap.entries()) { + for (const deviceToken of deviceTokens) { + try { + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId, + deviceToken, + messageType: 'batch', + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined, + collapseId: notificationData.collapseId, + }; + + const message = await this.pushMessageService.createMessage(messageData); + + // 查找对应的APNs结果 + const apnsResult = apnsResults.sent.find(s => s.device === deviceToken) || + apnsResults.failed.find(f => f.device === deviceToken); + + if (apnsResult) { + if ('device' in apnsResult && apnsResult.device === deviceToken) { + // 成功发送 + await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult); + await this.pushTokenService.updateLastUsedTime(deviceToken); + results.push({ + userId, + deviceToken, + success: true, + apnsResponse: apnsResult, + }); + successCount++; + } else { + // 发送失败 + const failure = apnsResult as any; + const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; + + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + failure.response, + errorMessage + ); + + // 如果是无效令牌,停用该令牌 + if (failure.status === '410' || failure.response?.reason === 'Unregistered') { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } + + results.push({ + userId, + deviceToken, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } else { + // 未找到结果,标记为失败 + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + null, + 'No APNs result found' + ); + results.push({ + userId, + deviceToken, + success: false, + error: 'No APNs result found', + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to process batch push result for user ${userId}, device ${deviceToken}: ${error.message}`, error); + results.push({ + userId, + deviceToken, + success: false, + error: error.message, + }); + failedCount++; + } + } + } + + const success = failedCount === 0; + + return { + code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR, + message: success ? '批量推送发送成功' : '部分批量推送发送失败', + data: { + totalUsers, + totalTokens, + successCount, + failedCount, + results, + }, + }; + } catch (error) { + this.logger.error(`Failed to send batch push notification: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `批量推送发送失败: ${error.message}`, + data: { + totalUsers: notificationData.userIds.length, + totalTokens: 0, + successCount: 0, + failedCount: notificationData.userIds.length, + results: [], + }, + }; + } + } + + /** + * 发送静默推送 + */ + async sendSilentNotification(userId: string, payload: any): Promise { + try { + this.logger.log(`Sending silent push notification to user ${userId}`); + + const notificationData: SendPushNotificationDto = { + userIds: [userId], + title: '', + body: '', + payload, + pushType: PushType.BACKGROUND, + contentAvailable: true, + }; + + return this.sendNotification(notificationData); + } catch (error) { + this.logger.error(`Failed to send silent push notification: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `静默推送发送失败: ${error.message}`, + data: { + success: false, + sentCount: 0, + failedCount: 1, + results: [], + }, + }; + } + } + + /** + * 注册设备令牌 + */ + async registerToken(userId: string, tokenData: any): Promise { + try { + const token = await this.pushTokenService.registerToken(userId, tokenData); + return { + code: ResponseCode.SUCCESS, + message: '设备令牌注册成功', + data: { + success: true, + tokenId: token.id, + }, + }; + } catch (error) { + this.logger.error(`Failed to register device token: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `设备令牌注册失败: ${error.message}`, + data: { + success: false, + tokenId: '', + }, + }; + } + } + + /** + * 更新设备令牌 + */ + async updateToken(userId: string, tokenData: any): Promise { + try { + const token = await this.pushTokenService.updateToken(userId, tokenData); + return { + code: ResponseCode.SUCCESS, + message: '设备令牌更新成功', + data: { + success: true, + tokenId: token.id, + }, + }; + } catch (error) { + this.logger.error(`Failed to update device token: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `设备令牌更新失败: ${error.message}`, + data: { + success: false, + tokenId: '', + }, + }; + } + } + + /** + * 注销设备令牌 + */ + async unregisterToken(userId: string, deviceToken: string): Promise { + try { + await this.pushTokenService.unregisterToken(userId, deviceToken); + return { + code: ResponseCode.SUCCESS, + message: '设备令牌注销成功', + data: { + success: true, + }, + }; + } catch (error) { + this.logger.error(`Failed to unregister device token: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `设备令牌注销失败: ${error.message}`, + data: { + success: false, + }, + }; + } + } +} \ No newline at end of file diff --git a/src/push-notifications/push-template.controller.ts b/src/push-notifications/push-template.controller.ts new file mode 100644 index 0000000..650759a --- /dev/null +++ b/src/push-notifications/push-template.controller.ts @@ -0,0 +1,104 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { PushTemplateService } from './push-template.service'; +import { CreatePushTemplateDto } from './dto/create-push-template.dto'; +import { UpdatePushTemplateDto } from './dto/update-push-template.dto'; +import { PushTemplate } from './models/push-template.model'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@ApiTags('推送模板') +@Controller('push-notifications/templates') +@UseGuards(JwtAuthGuard) +export class PushTemplateController { + constructor(private readonly pushTemplateService: PushTemplateService) { } + + @Get() + @ApiOperation({ summary: '获取所有推送模板' }) + @ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] }) + async getAllTemplates(): Promise { + return this.pushTemplateService.getAllTemplates(); + } + + @Get('active') + @ApiOperation({ summary: '获取所有活跃推送模板' }) + @ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] }) + async getActiveTemplates(): Promise { + return this.pushTemplateService.getActiveTemplates(); + } + + @Get(':templateKey') + @ApiOperation({ summary: '获取推送模板' }) + @ApiParam({ name: 'templateKey', description: '模板键' }) + @ApiResponse({ status: 200, description: '获取成功', type: PushTemplate }) + async getTemplate(@Param('templateKey') templateKey: string): Promise { + return this.pushTemplateService.getTemplate(templateKey); + } + + @Get('id/:id') + @ApiOperation({ summary: '根据ID获取推送模板' }) + @ApiParam({ name: 'id', description: '模板ID' }) + @ApiResponse({ status: 200, description: '获取成功', type: PushTemplate }) + async getTemplateById(@Param('id') id: string): Promise { + return this.pushTemplateService.getTemplateById(id); + } + + @Post() + @ApiOperation({ summary: '创建推送模板' }) + @ApiResponse({ status: 201, description: '创建成功', type: PushTemplate }) + async createTemplate( + @Body() createTemplateDto: CreatePushTemplateDto, + ): Promise { + return this.pushTemplateService.createTemplate(createTemplateDto); + } + + @Put(':id') + @ApiOperation({ summary: '更新推送模板' }) + @ApiParam({ name: 'id', description: '模板ID' }) + @ApiResponse({ status: 200, description: '更新成功', type: PushTemplate }) + async updateTemplate( + @Param('id') id: string, + @Body() updateTemplateDto: UpdatePushTemplateDto, + ): Promise { + return this.pushTemplateService.updateTemplate(id, updateTemplateDto); + } + + @Delete(':id') + @ApiOperation({ summary: '删除推送模板' }) + @ApiParam({ name: 'id', description: '模板ID' }) + @ApiResponse({ status: 200, description: '删除成功' }) + async deleteTemplate(@Param('id') id: string): Promise { + return this.pushTemplateService.deleteTemplate(id); + } + + @Put(':id/toggle') + @ApiOperation({ summary: '激活/停用模板' }) + @ApiParam({ name: 'id', description: '模板ID' }) + @ApiResponse({ status: 200, description: '操作成功', type: PushTemplate }) + async toggleTemplateStatus( + @Param('id') id: string, + @Body() body: { isActive: boolean }, + ): Promise { + return this.pushTemplateService.toggleTemplateStatus(id, body.isActive); + } + + @Post('validate') + @ApiOperation({ summary: '验证模板变量' }) + @ApiResponse({ status: 200, description: '验证成功' }) + async validateTemplateVariables( + @Body() body: { template: string; requiredVariables: string[] }, + ): Promise<{ isValid: boolean; missingVariables: string[] }> { + return this.pushTemplateService.validateTemplateVariables(body.template, body.requiredVariables); + } + + @Post('extract-variables') + @ApiOperation({ summary: '提取模板变量' }) + @ApiResponse({ status: 200, description: '提取成功' }) + async extractTemplateVariables( + @Body() body: { template: string }, + ): Promise<{ variables: string[] }> { + const variables = this.pushTemplateService.extractTemplateVariables(body.template); + return { variables }; + } +} \ No newline at end of file diff --git a/src/push-notifications/push-template.service.ts b/src/push-notifications/push-template.service.ts new file mode 100644 index 0000000..284fc59 --- /dev/null +++ b/src/push-notifications/push-template.service.ts @@ -0,0 +1,280 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { PushTemplate } from './models/push-template.model'; +import { CreatePushTemplateDto } from './dto/create-push-template.dto'; +import { UpdatePushTemplateDto } from './dto/update-push-template.dto'; +import { RenderedTemplate } from './interfaces/push-notification.interface'; + +@Injectable() +export class PushTemplateService { + private readonly logger = new Logger(PushTemplateService.name); + + constructor( + @InjectModel(PushTemplate) + private readonly templateModel: typeof PushTemplate, + ) { } + + /** + * 创建推送模板 + */ + async createTemplate(templateData: CreatePushTemplateDto): Promise { + try { + this.logger.log(`Creating push template with key: ${templateData.templateKey}`); + + // 检查模板键是否已存在 + const existingTemplate = await this.templateModel.findOne({ + where: { + templateKey: templateData.templateKey, + }, + }); + + if (existingTemplate) { + throw new ConflictException(`Template with key '${templateData.templateKey}' already exists`); + } + + const template = await this.templateModel.create({ + templateKey: templateData.templateKey, + title: templateData.title, + body: templateData.body, + payloadTemplate: templateData.payloadTemplate, + pushType: templateData.pushType, + priority: templateData.priority || 10, + isActive: true, + }); + + this.logger.log(`Successfully created push template with key: ${templateData.templateKey}`); + return template; + } catch (error) { + this.logger.error(`Failed to create push template: ${error.message}`, error); + throw error; + } + } + + /** + * 更新推送模板 + */ + async updateTemplate(id: string, templateData: UpdatePushTemplateDto): Promise { + try { + this.logger.log(`Updating push template with ID: ${id}`); + + const template = await this.templateModel.findByPk(id); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); + } + + await template.update(templateData); + + this.logger.log(`Successfully updated push template with ID: ${id}`); + return template; + } catch (error) { + this.logger.error(`Failed to update push template: ${error.message}`, error); + throw error; + } + } + + /** + * 删除推送模板 + */ + async deleteTemplate(id: string): Promise { + try { + this.logger.log(`Deleting push template with ID: ${id}`); + + const template = await this.templateModel.findByPk(id); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); + } + + await template.destroy(); + + this.logger.log(`Successfully deleted push template with ID: ${id}`); + } catch (error) { + this.logger.error(`Failed to delete push template: ${error.message}`, error); + throw error; + } + } + + /** + * 获取模板 + */ + async getTemplate(templateKey: string): Promise { + try { + const template = await this.templateModel.findOne({ + where: { + templateKey, + isActive: true, + }, + }); + + if (!template) { + throw new NotFoundException(`Template with key '${templateKey}' not found or inactive`); + } + + return template; + } catch (error) { + this.logger.error(`Failed to get push template: ${error.message}`, error); + throw error; + } + } + + /** + * 根据ID获取模板 + */ + async getTemplateById(id: string): Promise { + try { + const template = await this.templateModel.findByPk(id); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); + } + + return template; + } catch (error) { + this.logger.error(`Failed to get push template by ID: ${error.message}`, error); + throw error; + } + } + + /** + * 获取所有模板 + */ + async getAllTemplates(): Promise { + try { + const templates = await this.templateModel.findAll({ + order: [['createdAt', 'DESC']], + }); + + return templates; + } catch (error) { + this.logger.error(`Failed to get all push templates: ${error.message}`, error); + throw error; + } + } + + /** + * 获取所有活跃模板 + */ + async getActiveTemplates(): Promise { + try { + const templates = await this.templateModel.findAll({ + where: { + isActive: true, + }, + order: [['createdAt', 'DESC']], + }); + + return templates; + } catch (error) { + this.logger.error(`Failed to get active push templates: ${error.message}`, error); + throw error; + } + } + + /** + * 渲染模板 + */ + async renderTemplate(templateKey: string, data: any): Promise { + try { + this.logger.log(`Rendering template with key: ${templateKey}`); + + const template = await this.getTemplate(templateKey); + + // 简单的模板变量替换 + const renderedTitle = this.replaceVariables(template.title, data); + const renderedBody = this.replaceVariables(template.body, data); + const renderedPayload = template.payloadTemplate + ? this.replaceVariables(JSON.stringify(template.payloadTemplate), data) + : null; + + const renderedTemplate: RenderedTemplate = { + title: renderedTitle, + body: renderedBody, + payload: renderedPayload ? JSON.parse(renderedPayload) : undefined, + pushType: template.pushType, + priority: template.priority, + }; + + this.logger.log(`Successfully rendered template with key: ${templateKey}`); + return renderedTemplate; + } catch (error) { + this.logger.error(`Failed to render template: ${error.message}`, error); + throw error; + } + } + + /** + * 激活/停用模板 + */ + async toggleTemplateStatus(id: string, isActive: boolean): Promise { + try { + this.logger.log(`Toggling template status for ID: ${id} to ${isActive}`); + + const template = await this.templateModel.findByPk(id); + if (!template) { + throw new NotFoundException(`Template with ID ${id} not found`); + } + + await template.update({ + isActive, + }); + + this.logger.log(`Successfully toggled template status for ID: ${id}`); + return template; + } catch (error) { + this.logger.error(`Failed to toggle template status: ${error.message}`, error); + throw error; + } + } + + /** + * 替换模板变量 + */ + private replaceVariables(template: string, data: any): string { + if (!template || !data) { + return template; + } + + let result = template; + + // 替换 {{variable}} 格式的变量 + Object.keys(data).forEach(key => { + const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); + result = result.replace(regex, data[key]); + }); + + return result; + } + + /** + * 验证模板变量 + */ + validateTemplateVariables(template: string, requiredVariables: string[]): { isValid: boolean; missingVariables: string[] } { + const variableRegex = /{{\s*([^}]+)\s*}}/g; + const foundVariables: string[] = []; + let match; + + while ((match = variableRegex.exec(template)) !== null) { + foundVariables.push(match[1].trim()); + } + + const missingVariables = requiredVariables.filter(variable => !foundVariables.includes(variable)); + + return { + isValid: missingVariables.length === 0, + missingVariables, + }; + } + + /** + * 获取模板变量列表 + */ + extractTemplateVariables(template: string): string[] { + const variableRegex = /{{\s*([^}]+)\s*}}/g; + const variables: string[] = []; + let match; + + while ((match = variableRegex.exec(template)) !== null) { + variables.push(match[1].trim()); + } + + return [...new Set(variables)]; // 去重 + } +} \ No newline at end of file diff --git a/src/push-notifications/push-token.service.ts b/src/push-notifications/push-token.service.ts new file mode 100644 index 0000000..0bcd969 --- /dev/null +++ b/src/push-notifications/push-token.service.ts @@ -0,0 +1,332 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op } from 'sequelize'; +import { UserPushToken } from './models/user-push-token.model'; +import { DeviceType } from './enums/device-type.enum'; +import { RegisterDeviceTokenDto } from './dto/register-device-token.dto'; +import { UpdateDeviceTokenDto } from './dto/update-device-token.dto'; + +@Injectable() +export class PushTokenService { + private readonly logger = new Logger(PushTokenService.name); + + constructor( + @InjectModel(UserPushToken) + private readonly pushTokenModel: typeof UserPushToken, + ) { } + + /** + * 注册设备令牌 + */ + async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise { + try { + this.logger.log(`Registering push token for user ${userId}`); + + // 检查是否已存在相同的令牌 + const existingToken = await this.pushTokenModel.findOne({ + where: { + userId, + deviceToken: tokenData.deviceToken, + }, + }); + + if (existingToken) { + // 更新现有令牌信息 + await existingToken.update({ + deviceType: tokenData.deviceType, + appVersion: tokenData.appVersion, + osVersion: tokenData.osVersion, + deviceName: tokenData.deviceName, + isActive: true, + lastUsedAt: new Date(), + }); + + this.logger.log(`Updated existing push token for user ${userId}`); + return existingToken; + } + + // 检查用户是否已有其他设备的令牌,可以选择是否停用旧令牌 + const userTokens = await this.pushTokenModel.findAll({ + where: { + userId, + isActive: true, + }, + }); + + // 创建新令牌 + const newToken = await this.pushTokenModel.create({ + userId, + deviceToken: tokenData.deviceToken, + deviceType: tokenData.deviceType, + appVersion: tokenData.appVersion, + osVersion: tokenData.osVersion, + deviceName: tokenData.deviceName, + isActive: true, + lastUsedAt: new Date(), + }); + + this.logger.log(`Successfully registered new push token for user ${userId}`); + return newToken; + } catch (error) { + this.logger.error(`Failed to register push token for user ${userId}: ${error.message}`, error); + throw error; + } + } + + /** + * 更新设备令牌 + */ + async updateToken(userId: string, tokenData: UpdateDeviceTokenDto): Promise { + try { + this.logger.log(`Updating push token for user ${userId}`); + + // 查找当前令牌 + const currentToken = await this.pushTokenModel.findOne({ + where: { + userId, + deviceToken: tokenData.currentDeviceToken, + isActive: true, + }, + }); + + if (!currentToken) { + throw new NotFoundException('Current device token not found or inactive'); + } + + // 检查新令牌是否已存在 + const existingNewToken = await this.pushTokenModel.findOne({ + where: { + userId, + deviceToken: tokenData.newDeviceToken, + }, + }); + + if (existingNewToken) { + // 如果新令牌已存在,激活它并停用当前令牌 + await existingNewToken.update({ + isActive: true, + lastUsedAt: new Date(), + appVersion: tokenData.appVersion || existingNewToken.appVersion, + osVersion: tokenData.osVersion || existingNewToken.osVersion, + deviceName: tokenData.deviceName || existingNewToken.deviceName, + }); + + await currentToken.update({ + isActive: false, + }); + + this.logger.log(`Activated existing new token and deactivated old token for user ${userId}`); + return existingNewToken; + } + + // 更新当前令牌为新令牌 + await currentToken.update({ + deviceToken: tokenData.newDeviceToken, + appVersion: tokenData.appVersion, + osVersion: tokenData.osVersion, + deviceName: tokenData.deviceName, + lastUsedAt: new Date(), + }); + + this.logger.log(`Successfully updated push token for user ${userId}`); + return currentToken; + } catch (error) { + this.logger.error(`Failed to update push token for user ${userId}: ${error.message}`, error); + throw error; + } + } + + /** + * 注销设备令牌 + */ + async unregisterToken(userId: string, deviceToken: string): Promise { + try { + this.logger.log(`Unregistering push token for user ${userId}`); + + const token = await this.pushTokenModel.findOne({ + where: { + userId, + deviceToken, + isActive: true, + }, + }); + + if (!token) { + throw new NotFoundException('Device token not found or inactive'); + } + + await token.update({ + isActive: false, + }); + + this.logger.log(`Successfully unregistered push token for user ${userId}`); + } catch (error) { + this.logger.error(`Failed to unregister push token for user ${userId}: ${error.message}`, error); + throw error; + } + } + + /** + * 获取用户的所有有效令牌 + */ + async getActiveTokens(userId: string): Promise { + try { + const tokens = await this.pushTokenModel.findAll({ + where: { + userId, + isActive: true, + }, + order: [['lastUsedAt', 'DESC']], + }); + + this.logger.log(`Found ${tokens.length} active tokens for user ${userId}`); + return tokens; + } catch (error) { + this.logger.error(`Failed to get active tokens for user ${userId}: ${error.message}`, error); + throw error; + } + } + + /** + * 获取用户的所有令牌(包括非活跃的) + */ + async getAllTokens(userId: string): Promise { + try { + const tokens = await this.pushTokenModel.findAll({ + where: { + userId, + }, + order: [['createdAt', 'DESC']], + }); + + this.logger.log(`Found ${tokens.length} total tokens for user ${userId}`); + return tokens; + } catch (error) { + this.logger.error(`Failed to get all tokens for user ${userId}: ${error.message}`, error); + throw error; + } + } + + /** + * 验证令牌有效性 + */ + async validateToken(deviceToken: string): Promise { + try { + const token = await this.pushTokenModel.findOne({ + where: { + deviceToken, + isActive: true, + }, + }); + + return !!token; + } catch (error) { + this.logger.error(`Failed to validate token: ${error.message}`, error); + return false; + } + } + + /** + * 清理无效令牌 + */ + async cleanupInvalidTokens(): Promise { + try { + this.logger.log('Starting cleanup of invalid tokens'); + + // 清理超过30天未使用的令牌 + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const result = await this.pushTokenModel.update( + { + isActive: false, + }, + { + where: { + isActive: true, + lastUsedAt: { + [Op.lt]: thirtyDaysAgo, + }, + }, + }, + ); + + const cleanedCount = result[0]; + this.logger.log(`Cleaned up ${cleanedCount} inactive tokens`); + return cleanedCount; + } catch (error) { + this.logger.error(`Failed to cleanup invalid tokens: ${error.message}`, error); + throw error; + } + } + + /** + * 根据设备令牌获取用户ID + */ + async getUserIdByDeviceToken(deviceToken: string): Promise { + try { + const token = await this.pushTokenModel.findOne({ + where: { + deviceToken, + isActive: true, + }, + }); + + return token ? token.userId : null; + } catch (error) { + this.logger.error(`Failed to get user ID by device token: ${error.message}`, error); + return null; + } + } + + /** + * 批量获取用户的设备令牌 + */ + async getDeviceTokensByUserIds(userIds: string[]): Promise> { + try { + const tokens = await this.pushTokenModel.findAll({ + where: { + userId: { + [Op.in]: userIds, + }, + isActive: true, + }, + }); + + const userTokensMap = new Map(); + + tokens.forEach((token) => { + if (!userTokensMap.has(token.userId)) { + userTokensMap.set(token.userId, []); + } + userTokensMap.get(token.userId)!.push(token.deviceToken); + }); + + return userTokensMap; + } catch (error) { + this.logger.error(`Failed to get device tokens by user IDs: ${error.message}`, error); + throw error; + } + } + + /** + * 更新令牌最后使用时间 + */ + async updateLastUsedTime(deviceToken: string): Promise { + try { + await this.pushTokenModel.update( + { + lastUsedAt: new Date(), + }, + { + where: { + deviceToken, + isActive: true, + }, + }, + ); + } catch (error) { + this.logger.error(`Failed to update last used time: ${error.message}`, error); + } + } +} \ No newline at end of file