From 305a9699124b7a7b946ed9083238509522820571 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sat, 11 Oct 2025 17:38:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ios-push-implementation-plan.md | 288 ++++++++ docs/ios-push-notification-design.md | 265 +++++++ docs/push-notifications-usage-guide.md | 474 ++++++++++++ docs/push-service-architecture.md | 673 ++++++++++++++++++ package.json | 3 +- .../push-notifications-tables-create.sql | 72 ++ src/app.module.ts | 2 + src/push-notifications/apns.provider.ts | 301 ++++++++ .../dto/create-push-template.dto.ts | 35 + .../dto/push-response.dto.ts | 93 +++ .../dto/register-device-token.dto.ts | 29 + .../dto/send-push-by-template.dto.ts | 38 + .../dto/send-push-notification.dto.ts | 63 ++ .../dto/update-device-token.dto.ts | 29 + .../dto/update-push-template.dto.ts | 35 + .../enums/device-type.enum.ts | 4 + .../enums/push-message-status.enum.ts | 6 + .../enums/push-type.enum.ts | 6 + .../interfaces/apns-config.interface.ts | 25 + .../interfaces/push-notification.interface.ts | 65 ++ .../models/push-message.model.ts | 147 ++++ .../models/push-template.model.ts | 95 +++ .../models/user-push-token.model.ts | 100 +++ .../push-message.service.ts | 387 ++++++++++ .../push-notifications.controller.ts | 85 +++ .../push-notifications.module.ts | 45 ++ .../push-notifications.service.ts | 502 +++++++++++++ .../push-template.controller.ts | 104 +++ .../push-template.service.ts | 280 ++++++++ src/push-notifications/push-token.service.ts | 332 +++++++++ 30 files changed, 4582 insertions(+), 1 deletion(-) create mode 100644 docs/ios-push-implementation-plan.md create mode 100644 docs/ios-push-notification-design.md create mode 100644 docs/push-notifications-usage-guide.md create mode 100644 docs/push-service-architecture.md create mode 100644 sql-scripts/push-notifications-tables-create.sql create mode 100644 src/push-notifications/apns.provider.ts create mode 100644 src/push-notifications/dto/create-push-template.dto.ts create mode 100644 src/push-notifications/dto/push-response.dto.ts create mode 100644 src/push-notifications/dto/register-device-token.dto.ts create mode 100644 src/push-notifications/dto/send-push-by-template.dto.ts create mode 100644 src/push-notifications/dto/send-push-notification.dto.ts create mode 100644 src/push-notifications/dto/update-device-token.dto.ts create mode 100644 src/push-notifications/dto/update-push-template.dto.ts create mode 100644 src/push-notifications/enums/device-type.enum.ts create mode 100644 src/push-notifications/enums/push-message-status.enum.ts create mode 100644 src/push-notifications/enums/push-type.enum.ts create mode 100644 src/push-notifications/interfaces/apns-config.interface.ts create mode 100644 src/push-notifications/interfaces/push-notification.interface.ts create mode 100644 src/push-notifications/models/push-message.model.ts create mode 100644 src/push-notifications/models/push-template.model.ts create mode 100644 src/push-notifications/models/user-push-token.model.ts create mode 100644 src/push-notifications/push-message.service.ts create mode 100644 src/push-notifications/push-notifications.controller.ts create mode 100644 src/push-notifications/push-notifications.module.ts create mode 100644 src/push-notifications/push-notifications.service.ts create mode 100644 src/push-notifications/push-template.controller.ts create mode 100644 src/push-notifications/push-template.service.ts create mode 100644 src/push-notifications/push-token.service.ts 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 From 38dd740c8c52e9ccd866cbf9758f56c03797167e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 14 Oct 2025 19:25:30 +0800 Subject: [PATCH 2/4] feat(push): migrate APNs provider from @parse/node-apn to apns2 library - Replace @parse/node-apn with apns2 for improved APNs integration - Update ApnsProvider to use new ApnsClient with modern API - Refactor notification creation and sending logic for better error handling - Add proper error event listeners for device token issues - Update configuration interface to match apns2 requirements - Modify push notification endpoints to allow public access for token registration - Update service methods to handle new response format from apns2 - Add UsersModule dependency to PushNotificationsModule --- package-lock.json | 175 ++++++++- package.json | 3 +- src/push-notifications/apns.provider.ts | 332 +++++++++++------- .../interfaces/apns-config.interface.ts | 31 +- .../push-notifications.controller.ts | 12 +- .../push-notifications.module.ts | 2 + .../push-notifications.service.ts | 13 +- src/push-notifications/push-token.service.ts | 9 +- yarn.lock | 114 +++++- 9 files changed, 520 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8492f71..3fbb1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,14 +16,17 @@ "@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", + "apns2": "^12.2.0", "axios": "^1.10.0", "body-parser": "^2.2.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cos-nodejs-sdk-v5": "^2.14.7", "crypto-js": "^4.2.0", + "dayjs": "^1.11.18", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", @@ -1885,6 +1888,15 @@ "node": ">=8" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.15.1", "resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", @@ -2586,6 +2598,79 @@ "npm": ">=5.10.0" } }, + "node_modules/@parse/node-apn": { + "version": "5.2.3", + "resolved": "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz", + "integrity": "sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ==", + "license": "MIT", + "dependencies": { + "debug": "4.3.3", + "jsonwebtoken": "9.0.0", + "node-forge": "1.3.1", + "verror": "1.10.1" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@parse/node-apn/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/@parse/node-apn/node_modules/debug": { + "version": "4.3.3", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@parse/node-apn/node_modules/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/@parse/node-apn/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/@parse/node-apn/node_modules/verror": { + "version": "1.10.1", + "resolved": "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/@pkgr/core": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", @@ -4330,6 +4415,19 @@ "node": ">= 8" } }, + "node_modules/apns2": { + "version": "12.2.0", + "resolved": "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz", + "integrity": "sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA==", + "license": "MIT", + "dependencies": { + "fast-jwt": "^6.0.1", + "undici": "^7.9.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -4393,6 +4491,17 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4746,6 +4855,11 @@ "node": ">= 0.8.0" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + }, "node_modules/bodec": { "version": "0.1.0", "resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz", @@ -5680,10 +5794,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "dev": true, + "version": "1.11.18", + "resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, "node_modules/debounce-fn": { @@ -6815,6 +6928,21 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-jwt": { + "version": "6.0.2", + "resolved": "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz", + "integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -9536,6 +9664,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9589,6 +9723,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/module-details-from-path": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz", @@ -9847,6 +9990,15 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9936,6 +10088,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12901,6 +13059,15 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 432ac86..f80beef 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", "@types/uuid": "^10.0.0", + "apns2": "^12.2.0", "axios": "^1.10.0", "body-parser": "^2.2.0", "class-transformer": "^0.5.1", @@ -106,4 +107,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/src/push-notifications/apns.provider.ts b/src/push-notifications/apns.provider.ts index e64b006..dc3219f 100644 --- a/src/push-notifications/apns.provider.ts +++ b/src/push-notifications/apns.provider.ts @@ -1,15 +1,23 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import * as apn from '@parse/node-apn'; +import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2'; import * as fs from 'fs'; -import * as path from 'path'; import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface'; +interface SendResult { + sent: string[]; + failed: Array<{ + device: string; + error?: Error; + status?: string; + response?: any; + }>; +} + @Injectable() export class ApnsProvider implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ApnsProvider.name); - private provider: apn.Provider; - private multiProvider: apn.MultiProvider; + private client: ApnsClient; private config: ApnsConfig; constructor(private readonly configService: ConfigService) { @@ -18,7 +26,8 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { async onModuleInit() { try { - await this.initializeProvider(); + await this.initializeClient(); + this.setupErrorHandlers(); this.logger.log('APNs Provider initialized successfully'); } catch (error) { this.logger.error('Failed to initialize APNs Provider', error); @@ -28,7 +37,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { async onModuleDestroy() { try { - this.shutdown(); + await this.shutdown(); this.logger.log('APNs Provider shutdown successfully'); } catch (error) { this.logger.error('Error during APNs Provider shutdown', error); @@ -39,25 +48,24 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { * 构建APNs配置 */ private buildConfig(): ApnsConfig { - const keyId = this.configService.get('APNS_KEY_ID'); const teamId = this.configService.get('APNS_TEAM_ID'); + const keyId = this.configService.get('APNS_KEY_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) { + if (!teamId || !keyId || !keyPath || !bundleId) { throw new Error('Missing required APNs configuration'); } - let key: string | Buffer; + let signingKey: string | Buffer; try { // 尝试读取密钥文件 if (fs.existsSync(keyPath)) { - key = fs.readFileSync(keyPath); + signingKey = fs.readFileSync(keyPath); } else { // 如果是直接的内容而不是文件路径 - key = keyPath; + signingKey = keyPath; } } catch (error) { this.logger.error(`Failed to read APNs key file: ${keyPath}`, error); @@ -65,49 +73,79 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { } return { - token: { - key, - keyId, - teamId, - }, + team: teamId, + keyId, + signingKey, + defaultTopic: bundleId, + host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com', + port: 443, 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连接 + * 初始化APNs客户端 */ - private async initializeProvider(): Promise { + private async initializeClient(): 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'}`); + this.client = new ApnsClient(this.config); + this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`); } catch (error) { - this.logger.error('Failed to initialize APNs Provider', error); + this.logger.error('Failed to initialize APNs Client', error); throw error; } } + /** + * 设置错误处理器 + */ + private setupErrorHandlers(): void { + // 监听特定错误 + this.client.on(Errors.badDeviceToken, (err) => { + this.logger.error(`Bad device token: ${err.deviceToken}`, err.reason); + }); + + this.client.on(Errors.unregistered, (err) => { + this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason); + }); + + this.client.on(Errors.topicDisallowed, (err) => { + this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason); + }); + + // 监听所有错误 + this.client.on(Errors.error, (err) => { + this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err); + }); + } + /** * 发送单个通知 */ - async send(notification: apn.Notification, deviceTokens: string[]): Promise { + async send(notification: Notification, deviceTokens: string[]): Promise { + const results: SendResult = { + sent: [], + failed: [] + }; + try { this.logger.debug(`Sending notification to ${deviceTokens.length} devices`); - const results = await this.provider.send(notification, deviceTokens); + for (const deviceToken of deviceTokens) { + try { + // 为每个设备令牌创建新的通知实例 + const deviceNotification = this.createDeviceNotification(notification, deviceToken); + await this.client.send(deviceNotification); + results.sent.push(deviceToken); + } catch (error) { + results.failed.push({ + device: deviceToken, + error: error as Error + }); + } + } this.logResults(results); - return results; } catch (error) { this.logger.error('Error sending notification', error); @@ -118,14 +156,40 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 批量发送通知 */ - async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise { + async sendBatch(notifications: Notification[], deviceTokens: string[]): Promise { + const results: SendResult = { + sent: [], + failed: [] + }; + try { this.logger.debug(`Sending ${notifications.length} notifications to ${deviceTokens.length} devices`); - const results = await this.multiProvider.send(notifications, deviceTokens); + const deviceNotifications: Notification[] = []; + for (const notification of notifications) { + for (const deviceToken of deviceTokens) { + deviceNotifications.push(this.createDeviceNotification(notification, deviceToken)); + } + } + + const sendResults = await this.client.sendMany(deviceNotifications); + + // 处理 sendMany 的结果 + sendResults.forEach((result, index) => { + const deviceIndex = index % deviceTokens.length; + const deviceToken = deviceTokens[deviceIndex]; + + if (result && typeof result === 'object' && 'error' in result) { + results.failed.push({ + device: deviceToken, + error: (result as any).error + }); + } else { + results.sent.push(deviceToken); + } + }); this.logResults(results); - return results; } catch (error) { this.logger.error('Error sending batch notifications', error); @@ -136,15 +200,15 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 管理推送通道 */ - async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise { + async manageChannels(notification: 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); + // apns2 库没有直接的 manageChannels 方法,这里需要实现自定义逻辑 + // 或者使用原始的 HTTP 请求来管理通道 + this.logger.warn(`Channel management not directly supported in apns2 library. Action: ${action}`); - this.logger.log(`Channel management completed: ${JSON.stringify(results)}`); - - return results; + return { message: 'Channel management not implemented in apns2 library' }; } catch (error) { this.logger.error('Error managing channels', error); throw error; @@ -154,15 +218,15 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 广播实时活动通知 */ - async broadcast(notification: apn.Notification, bundleId: string): Promise { + async broadcast(notification: Notification, bundleId: string): Promise { try { this.logger.debug(`Broadcasting to bundle ${bundleId}`); - const results = await this.provider.broadcast(notification, bundleId); + // apns2 库没有直接的 broadcast 方法,这里需要实现自定义逻辑 + // 或者使用原始的 HTTP 请求来广播 + this.logger.warn(`Broadcast not directly supported in apns2 library. Bundle: ${bundleId}`); - this.logger.log(`Broadcast completed: ${JSON.stringify(results)}`); - - return results; + return { message: 'Broadcast not implemented in apns2 library' }; } catch (error) { this.logger.error('Error broadcasting', error); throw error; @@ -172,88 +236,109 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 创建标准通知 */ - 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(); + createNotification(options: ApnsNotificationOptions): Notification { + // 构建通知选项 + const notificationOptions: any = {}; - // 设置基本内容 - if (options.title) { - notification.title = options.title; + // 设置 APS 属性 + const aps: any = {}; + + if (options.badge !== undefined) { + notificationOptions.badge = options.badge; } - 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; + notificationOptions.sound = options.sound; } - // 设置徽章 - if (options.badge) { - notification.badge = options.badge; - } - - // 设置可变内容 - if (options.mutableContent) { - notification.mutableContent = 1; - } - - // 设置静默推送 if (options.contentAvailable) { - notification.contentAvailable = 1; + notificationOptions.contentAvailable = true; } - return notification; + if (options.mutableContent) { + notificationOptions.mutableContent = true; + } + + if (options.priority) { + notificationOptions.priority = options.priority; + } + + if (options.pushType) { + notificationOptions.type = options.pushType; + } + + // 添加自定义数据 + if (options.data) { + notificationOptions.data = options.data; + } + + // 创建通知对象,但不指定设备令牌(将在发送时设置) + return new Notification('', notificationOptions); + } + + /** + * 创建基本通知 + */ + createBasicNotification(deviceToken: string, title: string, body?: string, options?: Partial): Notification { + // 构建通知选项 + const notificationOptions: any = { + alert: { + title, + body: body || '' + } + }; + + if (options?.badge !== undefined) { + notificationOptions.badge = options.badge; + } + + if (options?.sound) { + notificationOptions.sound = options.sound; + } + + if (options?.contentAvailable) { + notificationOptions.contentAvailable = true; + } + + if (options?.mutableContent) { + notificationOptions.mutableContent = true; + } + + if (options?.priority) { + notificationOptions.priority = options.priority; + } + + if (options?.pushType) { + notificationOptions.type = options.pushType; + } + + // 添加自定义数据 + if (options?.data) { + notificationOptions.data = options.data; + } + + return new Notification(deviceToken, notificationOptions); + } + + /** + * 创建静默通知 + */ + createSilentNotification(deviceToken: string): SilentNotification { + return new SilentNotification(deviceToken); + } + + /** + * 为特定设备创建通知实例 + */ + private createDeviceNotification(notification: Notification, deviceToken: string): Notification { + // 创建新的通知实例,使用相同的选项但不同的设备令牌 + return new Notification(deviceToken, notification.options); } /** * 记录推送结果 */ - private logResults(results: apn.Results): void { + private logResults(results: SendResult): void { const { sent, failed } = results; this.logger.log(`Push results: ${sent.length} sent, ${failed.length} failed`); @@ -272,14 +357,10 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 关闭连接 */ - shutdown(): void { + async shutdown(): Promise { try { - if (this.provider) { - this.provider.shutdown(); - } - - if (this.multiProvider) { - this.multiProvider.shutdown(); + if (this.client) { + await this.client.close(); } this.logger.log('APNs Provider connections closed'); @@ -291,10 +372,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { /** * 获取Provider状态 */ - getStatus(): { connected: boolean; clientCount: number; environment: string } { + getStatus(): { connected: boolean; environment: string } { return { - connected: !!(this.provider || this.multiProvider), - clientCount: this.config.clientCount || 1, + connected: !!this.client, environment: this.config.production ? 'production' : 'sandbox', }; } diff --git a/src/push-notifications/interfaces/apns-config.interface.ts b/src/push-notifications/interfaces/apns-config.interface.ts index a4dcbed..1e75f0c 100644 --- a/src/push-notifications/interfaces/apns-config.interface.ts +++ b/src/push-notifications/interfaces/apns-config.interface.ts @@ -1,25 +1,26 @@ 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; + team: string; + keyId: string; + signingKey: string | Buffer; + defaultTopic: string; + host?: string; + port?: number; + production?: boolean; } export interface ApnsNotificationOptions { - topic: string; + topic?: string; id?: string; collapseId?: string; priority?: number; pushType?: string; expiry?: number; + badge?: number; + sound?: string; + contentAvailable?: boolean; + mutableContent?: boolean; + data?: Record; + title?: string; + body?: string; + alert?: any; } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.controller.ts b/src/push-notifications/push-notifications.controller.ts index d45eddf..1c51166 100644 --- a/src/push-notifications/push-notifications.controller.ts +++ b/src/push-notifications/push-notifications.controller.ts @@ -13,21 +13,24 @@ 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: '注册设备推送令牌' }) + @Public() @ApiResponse({ status: 200, description: '注册成功', type: RegisterTokenResponseDto }) async registerToken( @CurrentUser() user: AccessTokenPayload, @Body() registerTokenDto: RegisterDeviceTokenDto, ): Promise { - return this.pushNotificationsService.registerToken(user.sub, registerTokenDto); + return this.pushNotificationsService.registerToken(registerTokenDto, user.sub); } @Put('update-token') + @Public() + @ApiOperation({ summary: '更新设备推送令牌' }) @ApiResponse({ status: 200, description: '更新成功', type: UpdateTokenResponseDto }) async updateToken( @@ -38,6 +41,7 @@ export class PushNotificationsController { } @Delete('unregister-token') + @Public() @ApiOperation({ summary: '注销设备推送令牌' }) @ApiResponse({ status: 200, description: '注销成功', type: UnregisterTokenResponseDto }) async unregisterToken( @@ -49,6 +53,7 @@ export class PushNotificationsController { @Post('send') @ApiOperation({ summary: '发送推送通知' }) + @UseGuards(JwtAuthGuard) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) async sendNotification( @Body() sendNotificationDto: SendPushNotificationDto, @@ -58,6 +63,7 @@ export class PushNotificationsController { @Post('send-by-template') @ApiOperation({ summary: '使用模板发送推送' }) + @UseGuards(JwtAuthGuard) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) async sendNotificationByTemplate( @Body() sendByTemplateDto: SendPushByTemplateDto, @@ -67,6 +73,7 @@ export class PushNotificationsController { @Post('send-batch') @ApiOperation({ summary: '批量发送推送' }) + @UseGuards(JwtAuthGuard) @ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto }) async sendBatchNotifications( @Body() sendBatchDto: SendPushNotificationDto, @@ -76,6 +83,7 @@ export class PushNotificationsController { @Post('send-silent') @ApiOperation({ summary: '发送静默推送' }) + @UseGuards(JwtAuthGuard) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) async sendSilentNotification( @Body() body: { userId: string; payload: any }, diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts index a3194f2..ee45cfd 100644 --- a/src/push-notifications/push-notifications.module.ts +++ b/src/push-notifications/push-notifications.module.ts @@ -12,11 +12,13 @@ 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'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ ConfigModule, DatabaseModule, + UsersModule, SequelizeModule.forFeature([ UserPushToken, PushMessage, diff --git a/src/push-notifications/push-notifications.service.ts b/src/push-notifications/push-notifications.service.ts index f9f2b5a..fa0d8d8 100644 --- a/src/push-notifications/push-notifications.service.ts +++ b/src/push-notifications/push-notifications.service.ts @@ -80,7 +80,7 @@ export class PushNotificationsService { const apnsNotification = this.apnsProvider.createNotification({ title: notificationData.title, body: notificationData.body, - payload: notificationData.payload, + data: notificationData.payload, pushType: notificationData.pushType, priority: notificationData.priority, expiry: notificationData.expiry, @@ -239,7 +239,7 @@ export class PushNotificationsService { const apnsNotification = this.apnsProvider.createNotification({ title: notificationData.title, body: notificationData.body, - payload: notificationData.payload, + data: notificationData.payload, pushType: notificationData.pushType, priority: notificationData.priority, expiry: notificationData.expiry, @@ -290,11 +290,12 @@ export class PushNotificationsService { const message = await this.pushMessageService.createMessage(messageData); // 查找对应的APNs结果 - const apnsResult = apnsResults.sent.find(s => s.device === deviceToken) || + const apnsResult = apnsResults.sent.includes(deviceToken) ? + { device: deviceToken, success: true } : apnsResults.failed.find(f => f.device === deviceToken); if (apnsResult) { - if ('device' in apnsResult && apnsResult.device === deviceToken) { + if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) { // 成功发送 await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult); await this.pushTokenService.updateLastUsedTime(deviceToken); @@ -424,9 +425,9 @@ export class PushNotificationsService { /** * 注册设备令牌 */ - async registerToken(userId: string, tokenData: any): Promise { + async registerToken(tokenData: any, userId?: string,): Promise { try { - const token = await this.pushTokenService.registerToken(userId, tokenData); + const token = await this.pushTokenService.registerToken(tokenData, userId); return { code: ResponseCode.SUCCESS, message: '设备令牌注册成功', diff --git a/src/push-notifications/push-token.service.ts b/src/push-notifications/push-token.service.ts index 0bcd969..fa6ae30 100644 --- a/src/push-notifications/push-token.service.ts +++ b/src/push-notifications/push-token.service.ts @@ -18,7 +18,7 @@ export class PushTokenService { /** * 注册设备令牌 */ - async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise { + async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise { try { this.logger.log(`Registering push token for user ${userId}`); @@ -45,13 +45,6 @@ export class PushTokenService { return existingToken; } - // 检查用户是否已有其他设备的令牌,可以选择是否停用旧令牌 - const userTokens = await this.pushTokenModel.findAll({ - where: { - userId, - isActive: true, - }, - }); // 创建新令牌 const newToken = await this.pushTokenModel.create({ diff --git a/yarn.lock b/yarn.lock index e2dd542..8f9ff13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -872,6 +872,11 @@ resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== +"@lukeed/ms@^2.0.2": + version "2.0.2" + resolved "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz" + integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA== + "@microsoft/tsdoc@0.15.1": version "0.15.1" resolved "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz" @@ -1121,6 +1126,16 @@ dependencies: consola "^3.2.3" +"@parse/node-apn@^5.0.0": + version "5.2.3" + resolved "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz" + integrity sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ== + dependencies: + debug "4.3.3" + jsonwebtoken "9.0.0" + node-forge "1.3.1" + verror "1.10.1" + "@pkgr/core@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz" @@ -2156,6 +2171,14 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +apns2@^12.2.0: + version "12.2.0" + resolved "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz" + integrity sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA== + dependencies: + fast-jwt "^6.0.1" + undici "^7.9.0" + append-field@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz" @@ -2193,6 +2216,16 @@ asap@^2.0.0: resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^5.4.1: + version "5.4.1" + resolved "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + asn1@~0.2.3: version "0.2.6" resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz" @@ -2389,6 +2422,11 @@ blessed@0.1.81: resolved "https://mirrors.tencent.com/npm/blessed/-/blessed-0.1.81.tgz" integrity sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ== +bn.js@^4.0.0: + version "4.12.2" + resolved "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz" + integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== + bodec@^0.1.0: version "0.1.0" resolved "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz" @@ -2952,16 +2990,11 @@ data-uri-to-buffer@^6.0.2: resolved "https://mirrors.tencent.com/npm/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz" integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== -dayjs@^1.11.18: +dayjs@^1.11.18, dayjs@~1.11.13: version "1.11.18" - resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz#835fa712aac52ab9dec8b1494098774ed7070a11" + resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz" integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA== -dayjs@~1.11.13: - version "1.11.13" - resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== - dayjs@~1.8.24: version "1.8.36" resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.8.36.tgz" @@ -2981,6 +3014,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" +debug@4.3.3: + version "4.3.3" + resolved "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + debug@4.3.6: version "4.3.6" resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" @@ -3139,7 +3179,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://mirrors.tencent.com/npm/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -3572,6 +3612,16 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-jwt@^6.0.1: + version "6.0.2" + resolved "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz" + integrity sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA== + dependencies: + "@lukeed/ms" "^2.0.2" + asn1.js "^5.4.1" + ecdsa-sig-formatter "^1.0.11" + mnemonist "^0.40.0" + fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" @@ -4217,7 +4267,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4909,6 +4959,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@9.0.0: + version "9.0.0" + resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz" + integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== + dependencies: + jws "^3.2.2" + lodash "^4.17.21" + ms "^2.1.1" + semver "^7.3.8" + jsonwebtoken@9.0.2, jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" @@ -5295,6 +5355,11 @@ mimic-response@^4.0.0: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^10.0.0: version "10.0.1" resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz" @@ -5345,6 +5410,13 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.6" +mnemonist@^0.40.0: + version "0.40.3" + resolved "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz" + integrity sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ== + dependencies: + obliterator "^2.0.4" + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz" @@ -5477,6 +5549,11 @@ node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-forge@1.3.1: + version "1.3.1" + resolved "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -5524,6 +5601,11 @@ object-inspect@^1.13.3: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== +obliterator@^2.0.4: + version "2.0.5" + resolved "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz" + integrity sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw== + on-finished@2.4.1, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" @@ -7113,6 +7195,11 @@ undici-types@~6.20.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici@^7.9.0: + version "7.16.0" + resolved "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz" + integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" @@ -7196,6 +7283,15 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +verror@1.10.1: + version "1.10.1" + resolved "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz" + integrity sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg== + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + vizion@~2.2.1: version "2.2.1" resolved "https://mirrors.tencent.com/npm/vizion/-/vizion-2.2.1.tgz" From cc83b84c800f595dc829c84f8de0873684c50656 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 15 Oct 2025 19:09:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(push):=20=E6=96=B0=E5=A2=9E=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8E=A8=E9=80=81=E5=92=8C=E6=B5=8B=E8=AF=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增基于设备令牌的推送通知接口 - 添加推送测试服务,支持应用启动时自动测试 - 新增推送测试文档说明 - 更新 APNS 配置和日志记录 - 迁移至 apns2 库的 PushType 枚举 - 替换订阅密钥文件 - 添加项目规则文档 --- .kilocode/rules/rule.md | 8 + SubscriptionKey_3YKHQZ374P.p8 | 6 + SubscriptionKey_K3L2F8HFTS.p8 | 6 - src/push-notifications/README_PUSH_TEST.md | 132 ++++++++ src/push-notifications/apns.provider.ts | 15 +- .../dto/create-push-template.dto.ts | 2 +- .../dto/device-push-response.dto.ts | 51 +++ .../dto/send-push-notification.dto.ts | 2 +- .../dto/send-push-to-devices.dto.ts | 63 ++++ .../dto/update-push-template.dto.ts | 2 +- .../enums/push-type.enum.ts | 6 - .../interfaces/push-notification.interface.ts | 2 +- .../models/push-message.model.ts | 4 +- .../models/push-template.model.ts | 4 +- .../models/user-push-token.model.ts | 2 +- .../push-notifications.controller.ts | 28 +- .../push-notifications.module.ts | 3 + .../push-notifications.service.ts | 291 +++++++++++++++++- src/push-notifications/push-test.service.ts | 99 ++++++ src/push-notifications/push-token.service.ts | 39 ++- 20 files changed, 728 insertions(+), 37 deletions(-) create mode 100644 .kilocode/rules/rule.md create mode 100644 SubscriptionKey_3YKHQZ374P.p8 delete mode 100644 SubscriptionKey_K3L2F8HFTS.p8 create mode 100644 src/push-notifications/README_PUSH_TEST.md create mode 100644 src/push-notifications/dto/device-push-response.dto.ts create mode 100644 src/push-notifications/dto/send-push-to-devices.dto.ts delete mode 100644 src/push-notifications/enums/push-type.enum.ts create mode 100644 src/push-notifications/push-test.service.ts diff --git a/.kilocode/rules/rule.md b/.kilocode/rules/rule.md new file mode 100644 index 0000000..e678a2f --- /dev/null +++ b/.kilocode/rules/rule.md @@ -0,0 +1,8 @@ +# rule.md + +这是一个 nodejs 基于 nestjs 框架的项目 + +## 指导原则 + +- 不要随意新增 markdown 文档 +- 代码提交 message 用中文 diff --git a/SubscriptionKey_3YKHQZ374P.p8 b/SubscriptionKey_3YKHQZ374P.p8 new file mode 100644 index 0000000..1068aea --- /dev/null +++ b/SubscriptionKey_3YKHQZ374P.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V +UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH +GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F +Db0U/xrS +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/SubscriptionKey_K3L2F8HFTS.p8 b/SubscriptionKey_K3L2F8HFTS.p8 deleted file mode 100644 index 9dadc86..0000000 --- a/SubscriptionKey_K3L2F8HFTS.p8 +++ /dev/null @@ -1,6 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW -4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT -FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb -8U5D2WWu ------END PRIVATE KEY----- \ No newline at end of file diff --git a/src/push-notifications/README_PUSH_TEST.md b/src/push-notifications/README_PUSH_TEST.md new file mode 100644 index 0000000..e786adc --- /dev/null +++ b/src/push-notifications/README_PUSH_TEST.md @@ -0,0 +1,132 @@ +# 推送测试功能 + +本文档介绍如何使用推送测试功能,该功能可以在应用程序启动时自动获取表中的已有 token 进行消息推送。 + +## 功能概述 + +推送测试功能包括以下特性: + +1. **自动测试**:应用程序启动时自动执行推送测试(可通过环境变量控制) +2. **手动触发**:通过 API 接口手动触发推送测试 +3. **统计信息**:获取推送令牌的统计信息 +4. **可配置内容**:可以自定义测试推送的标题和内容 + +## 环境变量配置 + +在 `.env` 文件中添加以下配置: + +```env +# 推送测试配置 +# 启用/禁用应用启动时的推送测试 +ENABLE_PUSH_TEST=false + +# 测试推送消息内容(可选,如果不提供将使用默认值) +PUSH_TEST_TITLE=测试推送 +PUSH_TEST_BODY=这是一条测试推送消息,用于验证推送功能是否正常工作。 +``` + +### 环境变量说明 + +- `ENABLE_PUSH_TEST`: 设置为 `true` 启用应用启动时的推送测试,设置为 `false` 禁用(默认为 `false`) +- `PUSH_TEST_TITLE`: 测试推送的标题(可选) +- `PUSH_TEST_BODY`: 测试推送的内容(可选) + +## API 接口 + +### 1. 手动触发推送测试 + +**请求方式**: `POST` +**请求路径**: `/api/push-test/trigger` + +**响应示例**: +```json +{ + "code": 0, + "message": "Push test completed", + "data": { + "success": true, + "message": "Push test completed" + } +} +``` + +### 2. 获取推送令牌统计信息 + +**请求方式**: `GET` +**请求路径**: `/api/push-test/stats` + +**响应示例**: +```json +{ + "code": 0, + "message": "获取推送令牌统计信息成功", + "data": { + "totalTokens": 100, + "activeTokens": 85, + "inactiveTokens": 15, + "recentlyActiveTokens": 60 + } +} +``` + +## 工作原理 + +1. **自动测试流程**: + - 应用启动时,`PushTestService` 会检查 `ENABLE_PUSH_TEST` 环境变量 + - 如果启用,服务会在应用完全启动后 5 秒执行推送测试 + - 测试会获取最多 10 个活跃的推送令牌 + - 向这些令牌发送测试推送消息 + +2. **手动测试流程**: + - 通过调用 `/api/push-test/trigger` 接口手动触发测试 + - 测试流程与自动测试相同 + +3. **统计信息**: + - `totalTokens`: 总令牌数 + - `activeTokens`: 活跃令牌数 + - `inactiveTokens`: 非活跃令牌数 + - `recentlyActiveTokens`: 最近 7 天活跃的令牌数 + +## 注意事项 + +1. **生产环境使用**: + - 在生产环境中使用前,请确保测试推送内容不会对用户造成困扰 + - 建议在非高峰时段进行测试 + +2. **令牌限制**: + - 自动测试每次最多获取 10 个令牌,避免发送过多推送 + - 只会向标记为 `isActive=true` 的令牌发送推送 + +3. **错误处理**: + - 如果推送失败,服务会记录详细的错误日志 + - 无效的令牌会被自动标记为非活跃状态 + +## 日志记录 + +推送测试功能会记录以下日志: + +- 测试开始和结束 +- 发送成功和失败的统计 +- 每个令牌的推送结果 +- 错误详情 + +这些日志可以帮助排查推送问题。 + +## 示例用法 + +### 启用自动测试 + +1. 在 `.env` 文件中设置 `ENABLE_PUSH_TEST=true` +2. 重启应用程序 +3. 查看日志确认测试是否成功执行 + +### 手动触发测试 + +```bash +curl -X POST http://localhost:3002/api/push-test/trigger +``` + +### 查看统计信息 + +```bash +curl -X GET http://localhost:3002/api/push-test/stats \ No newline at end of file diff --git a/src/push-notifications/apns.provider.ts b/src/push-notifications/apns.provider.ts index dc3219f..f9c9533 100644 --- a/src/push-notifications/apns.provider.ts +++ b/src/push-notifications/apns.provider.ts @@ -77,9 +77,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { keyId, signingKey, defaultTopic: bundleId, - host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com', - port: 443, - production: environment === 'production', + // production: environment === 'production', }; } @@ -88,6 +86,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { */ private async initializeClient(): Promise { try { + this.logger.log(`Initializing APNs Client config: ${JSON.stringify(this.config)}`); this.client = new ApnsClient(this.config); this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`); } catch (error) { @@ -102,7 +101,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { private setupErrorHandlers(): void { // 监听特定错误 this.client.on(Errors.badDeviceToken, (err) => { - this.logger.error(`Bad device token: ${err.deviceToken}`, err.reason); + this.logger.error(`Bad device token: ${err}`, err.reason); }); this.client.on(Errors.unregistered, (err) => { @@ -129,12 +128,14 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { }; try { - this.logger.debug(`Sending notification to ${deviceTokens.length} devices`); for (const deviceToken of deviceTokens) { try { // 为每个设备令牌创建新的通知实例 const deviceNotification = this.createDeviceNotification(notification, deviceToken); + + this.logger.log(`Sending notification to device this.client.send deviceNotification ${JSON.stringify(deviceNotification, null, 2)}`); + await this.client.send(deviceNotification); results.sent.push(deviceToken); } catch (error) { @@ -332,7 +333,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { */ private createDeviceNotification(notification: Notification, deviceToken: string): Notification { // 创建新的通知实例,使用相同的选项但不同的设备令牌 - return new Notification(deviceToken, notification.options); + return new Notification(deviceToken, { + alert: notification.options.alert, + }); } /** diff --git a/src/push-notifications/dto/create-push-template.dto.ts b/src/push-notifications/dto/create-push-template.dto.ts index c59c22d..c7187e2 100644 --- a/src/push-notifications/dto/create-push-template.dto.ts +++ b/src/push-notifications/dto/create-push-template.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export class CreatePushTemplateDto { @ApiProperty({ description: '模板键' }) diff --git a/src/push-notifications/dto/device-push-response.dto.ts b/src/push-notifications/dto/device-push-response.dto.ts new file mode 100644 index 0000000..0b82588 --- /dev/null +++ b/src/push-notifications/dto/device-push-response.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ResponseCode } from '../../base.dto'; + +export class DevicePushResult { + @ApiProperty({ description: '设备令牌' }) + deviceToken: string; + + @ApiProperty({ description: '用户ID(可选,如果可获取)' }) + userId?: string; + + @ApiProperty({ description: '是否成功' }) + success: boolean; + + @ApiProperty({ description: '错误信息', required: false }) + error?: string; + + @ApiProperty({ description: 'APNs响应', required: false }) + apnsResponse?: any; +} + +export class DevicePushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '推送结果' }) + data: { + success: boolean; + sentCount: number; + failedCount: number; + results: DevicePushResult[]; + }; +} + +export class BatchDevicePushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '批量推送结果' }) + data: { + totalTokens: number; + successCount: number; + failedCount: number; + results: DevicePushResult[]; + }; +} \ 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 index 39feaae..3cbc958 100644 --- a/src/push-notifications/dto/send-push-notification.dto.ts +++ b/src/push-notifications/dto/send-push-notification.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export class SendPushNotificationDto { @ApiProperty({ description: '用户ID列表' }) diff --git a/src/push-notifications/dto/send-push-to-devices.dto.ts b/src/push-notifications/dto/send-push-to-devices.dto.ts new file mode 100644 index 0000000..6ad99fb --- /dev/null +++ b/src/push-notifications/dto/send-push-to-devices.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 'apns2'; + +export class SendPushToDevicesDto { + @ApiProperty({ description: '设备令牌列表' }) + @IsArray() + @IsString({ each: true }) + deviceTokens: 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-push-template.dto.ts b/src/push-notifications/dto/update-push-template.dto.ts index 7b7ba3d..17ed5a7 100644 --- a/src/push-notifications/dto/update-push-template.dto.ts +++ b/src/push-notifications/dto/update-push-template.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; +import { PushType } from 'apns2'; import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; export class UpdatePushTemplateDto { @ApiProperty({ description: '模板标题', required: false }) diff --git a/src/push-notifications/enums/push-type.enum.ts b/src/push-notifications/enums/push-type.enum.ts deleted file mode 100644 index d3b00a5..0000000 --- a/src/push-notifications/enums/push-type.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum PushType { - ALERT = 'ALERT', - BACKGROUND = 'BACKGROUND', - VOIP = 'VOIP', - LIVEACTIVITY = 'LIVEACTIVITY', -} \ 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 index f3ea3ac..036b27c 100644 --- a/src/push-notifications/interfaces/push-notification.interface.ts +++ b/src/push-notifications/interfaces/push-notification.interface.ts @@ -1,4 +1,4 @@ -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export interface PushNotificationRequest { userIds: string[]; diff --git a/src/push-notifications/models/push-message.model.ts b/src/push-notifications/models/push-message.model.ts index 69ffd98..8858d0a 100644 --- a/src/push-notifications/models/push-message.model.ts +++ b/src/push-notifications/models/push-message.model.ts @@ -1,6 +1,6 @@ import { Column, Model, Table, DataType, Index } from 'sequelize-typescript'; -import { PushType } from '../enums/push-type.enum'; import { PushMessageStatus } from '../enums/push-message-status.enum'; +import { PushType } from 'apns2'; @Table({ tableName: 't_push_messages', @@ -77,7 +77,7 @@ export class PushMessage extends Model { @Column({ type: DataType.ENUM(...Object.values(PushType)), allowNull: false, - defaultValue: PushType.ALERT, + defaultValue: PushType.alert, comment: '推送类型', }) declare pushType: PushType; diff --git a/src/push-notifications/models/push-template.model.ts b/src/push-notifications/models/push-template.model.ts index e935bae..166480e 100644 --- a/src/push-notifications/models/push-template.model.ts +++ b/src/push-notifications/models/push-template.model.ts @@ -1,5 +1,5 @@ import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; @Table({ tableName: 't_push_templates', @@ -58,7 +58,7 @@ export class PushTemplate extends Model { @Column({ type: DataType.ENUM(...Object.values(PushType)), allowNull: false, - defaultValue: PushType.ALERT, + defaultValue: PushType.alert, field: 'push_type', comment: '推送类型', }) diff --git a/src/push-notifications/models/user-push-token.model.ts b/src/push-notifications/models/user-push-token.model.ts index 2eed7a2..d435443 100644 --- a/src/push-notifications/models/user-push-token.model.ts +++ b/src/push-notifications/models/user-push-token.model.ts @@ -30,7 +30,7 @@ export class UserPushToken extends Model { @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, comment: '用户ID', }) declare userId: string; diff --git a/src/push-notifications/push-notifications.controller.ts b/src/push-notifications/push-notifications.controller.ts index 1c51166..197a3c1 100644 --- a/src/push-notifications/push-notifications.controller.ts +++ b/src/push-notifications/push-notifications.controller.ts @@ -5,7 +5,9 @@ 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 { SendPushToDevicesDto } from './dto/send-push-to-devices.dto'; import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto'; +import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-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'; @@ -25,7 +27,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() registerTokenDto: RegisterDeviceTokenDto, ): Promise { - return this.pushNotificationsService.registerToken(registerTokenDto, user.sub); + return this.pushNotificationsService.registerToken(registerTokenDto, user?.sub || ''); } @Put('update-token') @@ -37,7 +39,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() updateTokenDto: UpdateDeviceTokenDto, ): Promise { - return this.pushNotificationsService.updateToken(user.sub, updateTokenDto); + return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto); } @Delete('unregister-token') @@ -48,7 +50,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() body: { deviceToken: string }, ): Promise { - return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken); + return this.pushNotificationsService.unregisterToken(user?.sub || '', body.deviceToken); } @Post('send') @@ -90,4 +92,24 @@ export class PushNotificationsController { ): Promise { return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload); } + + @Post('send-to-devices') + @ApiOperation({ summary: '向指定设备发送推送通知' }) + @UseGuards(JwtAuthGuard) + @ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto }) + async sendNotificationToDevices( + @Body() sendToDevicesDto: SendPushToDevicesDto, + ): Promise { + return this.pushNotificationsService.sendNotificationToDevices(sendToDevicesDto); + } + + @Post('send-batch-to-devices') + @ApiOperation({ summary: '批量向指定设备发送推送通知' }) + @UseGuards(JwtAuthGuard) + @ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto }) + async sendBatchNotificationToDevices( + @Body() sendBatchToDevicesDto: SendPushToDevicesDto, + ): Promise { + return this.pushNotificationsService.sendBatchNotificationToDevices(sendBatchToDevicesDto); + } } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts index ee45cfd..56e6945 100644 --- a/src/push-notifications/push-notifications.module.ts +++ b/src/push-notifications/push-notifications.module.ts @@ -7,6 +7,7 @@ import { ApnsProvider } from './apns.provider'; import { PushTokenService } from './push-token.service'; import { PushTemplateService } from './push-template.service'; import { PushMessageService } from './push-message.service'; +import { PushTestService } from './push-test.service'; import { UserPushToken } from './models/user-push-token.model'; import { PushMessage } from './models/push-message.model'; import { PushTemplate } from './models/push-template.model'; @@ -35,6 +36,7 @@ import { UsersModule } from '../users/users.module'; PushTokenService, PushTemplateService, PushMessageService, + PushTestService, ], exports: [ ApnsProvider, @@ -42,6 +44,7 @@ import { UsersModule } from '../users/users.module'; PushTokenService, PushTemplateService, PushMessageService, + PushTestService, ], }) 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 index fa0d8d8..1bd9b24 100644 --- a/src/push-notifications/push-notifications.service.ts +++ b/src/push-notifications/push-notifications.service.ts @@ -6,11 +6,13 @@ 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 { SendPushToDevicesDto } from './dto/send-push-to-devices.dto'; import { PushResult, BatchPushResult } from './interfaces/push-notification.interface'; import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto'; +import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto'; import { ResponseCode } from '../base.dto'; -import { PushType } from './enums/push-type.enum'; import { PushMessageStatus } from './enums/push-message-status.enum'; +import { PushType } from 'apns2'; @Injectable() export class PushNotificationsService { @@ -402,7 +404,7 @@ export class PushNotificationsService { title: '', body: '', payload, - pushType: PushType.BACKGROUND, + pushType: PushType.background, contentAvailable: true, }; @@ -428,6 +430,7 @@ export class PushNotificationsService { async registerToken(tokenData: any, userId?: string,): Promise { try { const token = await this.pushTokenService.registerToken(tokenData, userId); + this.logger.log(`Registered device token for user ${userId}: ${token.id}`); return { code: ResponseCode.SUCCESS, message: '设备令牌注册成功', @@ -500,4 +503,288 @@ export class PushNotificationsService { }; } } + + /** + * 基于设备令牌发送推送通知 + */ + async sendNotificationToDevices(notificationData: SendPushToDevicesDto): Promise { + try { + this.logger.log(`Sending push notification to ${notificationData.deviceTokens.length} devices`); + + const results: DevicePushResult[] = []; + let sentCount = 0; + let failedCount = 0; + + // 为每个设备令牌创建消息记录并发送推送 + for (const deviceToken of notificationData.deviceTokens) { + try { + // 尝试获取设备令牌对应的用户ID + const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken); + + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId: 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, + data: 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({ + deviceToken, + userId: userId || undefined, + 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') { + if (userId) { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } else { + // 如果没有用户ID,直接停用令牌 + await this.pushTokenService.deactivateToken(deviceToken); + } + } + + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to send push to device ${deviceToken}: ${error.message}`, error); + results.push({ + 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 to devices: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `推送发送失败: ${error.message}`, + data: { + success: false, + sentCount: 0, + failedCount: notificationData.deviceTokens.length, + results: [], + }, + }; + } + } + + /** + * 批量基于设备令牌发送推送通知 + */ + async sendBatchNotificationToDevices(notificationData: SendPushToDevicesDto): Promise { + try { + this.logger.log(`Sending batch push notification to ${notificationData.deviceTokens.length} devices`); + + const results: DevicePushResult[] = []; + let totalTokens = notificationData.deviceTokens.length; + let successCount = 0; + let failedCount = 0; + + // 创建APNs通知 + const apnsNotification = this.apnsProvider.createNotification({ + alert: notificationData.title, + title: notificationData.title, + body: notificationData.body, + data: notificationData.payload, + pushType: notificationData.pushType, + topic: this.bundleId, + sound: notificationData.sound, + badge: notificationData.badge, + }); + + this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`); + + // 批量发送推送 + const apnsResults = await this.apnsProvider.send(apnsNotification, notificationData.deviceTokens); + + // 处理结果并创建消息记录 + for (const deviceToken of notificationData.deviceTokens) { + try { + // 尝试获取设备令牌对应的用户ID + const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken); + + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId: 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.includes(deviceToken) ? + { device: deviceToken, success: true } : + apnsResults.failed.find(f => f.device === deviceToken); + + if (apnsResult) { + if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) { + // 成功发送 + await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult); + await this.pushTokenService.updateLastUsedTime(deviceToken); + results.push({ + deviceToken, + userId: userId || undefined, + 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') { + if (userId) { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } else { + // 如果没有用户ID,直接停用令牌 + await this.pushTokenService.deactivateToken(deviceToken); + } + } + + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } else { + // 未找到结果,标记为失败 + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + null, + 'No APNs result found' + ); + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: 'No APNs result found', + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to process batch push result for device ${deviceToken}: ${error.message}`, error); + results.push({ + deviceToken, + success: false, + error: error.message, + }); + failedCount++; + } + } + + const success = failedCount === 0; + + return { + code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR, + message: success ? '批量推送发送成功' : '部分批量推送发送失败', + data: { + totalTokens, + successCount, + failedCount, + results, + }, + }; + } catch (error) { + this.logger.error(`Failed to send batch push notification to devices: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `批量推送发送失败: ${error.message}`, + data: { + totalTokens: notificationData.deviceTokens.length, + successCount: 0, + failedCount: notificationData.deviceTokens.length, + results: [], + }, + }; + } + } } \ No newline at end of file diff --git a/src/push-notifications/push-test.service.ts b/src/push-notifications/push-test.service.ts new file mode 100644 index 0000000..1ef491a --- /dev/null +++ b/src/push-notifications/push-test.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PushNotificationsService } from './push-notifications.service'; +import { PushTokenService } from './push-token.service'; +import { UserPushToken } from './models/user-push-token.model'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op } from 'sequelize'; +import { PushType } from 'apns2'; + +@Injectable() +export class PushTestService implements OnModuleInit { + private readonly logger = new Logger(PushTestService.name); + + constructor( + @InjectModel(UserPushToken) + private readonly pushTokenModel: typeof UserPushToken, + private readonly pushNotificationsService: PushNotificationsService, + private readonly pushTokenService: PushTokenService, + private readonly configService: ConfigService, + ) { } + + /** + * 模块初始化时执行 + */ + async onModuleInit() { + // 检查是否启用推送测试 + const enablePushTest = this.configService.get('ENABLE_PUSH_TEST', false); + + if (!enablePushTest) { + this.logger.log('Push test is disabled. Skipping...'); + return; + } + + // 延迟执行,确保应用完全启动 + setTimeout(async () => { + try { + await this.performPushTest(); + } catch (error) { + this.logger.error(`Push test failed: ${error.message}`, error); + } + }, 5000); // 5秒后执行 + } + + /** + * 执行推送测试 + */ + private async performPushTest(): Promise { + this.logger.log('Starting push test...'); + + try { + // 获取所有活跃的推送令牌 + const activeTokens = await this.pushTokenModel.findAll({ + where: { + isActive: true, + }, + limit: 10, // 限制测试数量,避免发送过多推送 + }); + + if (activeTokens.length === 0) { + this.logger.log('No active push tokens found for testing'); + return; + } + + this.logger.log(`Found ${activeTokens.length} active tokens for testing`); + + // 准备测试推送内容 + const testTitle = this.configService.get('PUSH_TEST_TITLE', '测试推送'); + const testBody = this.configService.get('PUSH_TEST_BODY', '这是一条测试推送消息,用于验证推送功能是否正常工作。'); + + // 发送测试推送 + const result = await this.pushNotificationsService.sendBatchNotificationToDevices({ + deviceTokens: activeTokens.map(token => token.deviceToken), + title: testTitle, + body: testBody, + pushType: PushType.alert, + }); + + if (result.code === 0) { + this.logger.log(`Push test completed successfully. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`); + } else { + this.logger.warn(`Push test completed with issues. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`); + } + + // 记录详细结果 + if (result.data.results && result.data.results.length > 0) { + result.data.results.forEach((resultItem, index) => { + if (resultItem.success) { + this.logger.log(`Push test success for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...`); + } else { + this.logger.warn(`Push test failed for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...: ${resultItem.error}`); + } + }); + } + } catch (error) { + this.logger.error(`Error during push test: ${error.message}`, error); + } + } + +} \ No newline at end of file diff --git a/src/push-notifications/push-token.service.ts b/src/push-notifications/push-token.service.ts index fa6ae30..ee0469f 100644 --- a/src/push-notifications/push-token.service.ts +++ b/src/push-notifications/push-token.service.ts @@ -20,12 +20,11 @@ export class PushTokenService { */ async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise { try { - this.logger.log(`Registering push token for user ${userId}`); + this.logger.log(`Registering push token for device ${tokenData.deviceToken}`); // 检查是否已存在相同的令牌 const existingToken = await this.pushTokenModel.findOne({ where: { - userId, deviceToken: tokenData.deviceToken, }, }); @@ -41,7 +40,7 @@ export class PushTokenService { lastUsedAt: new Date(), }); - this.logger.log(`Updated existing push token for user ${userId}`); + this.logger.log(`Updated existing push token for device ${tokenData.deviceToken}`); return existingToken; } @@ -58,10 +57,10 @@ export class PushTokenService { lastUsedAt: new Date(), }); - this.logger.log(`Successfully registered new push token for user ${userId}`); + this.logger.log(`Successfully registered new push token for device ${tokenData.deviceToken}`); return newToken; } catch (error) { - this.logger.error(`Failed to register push token for user ${userId}: ${error.message}`, error); + this.logger.error(`Failed to register push token for device ${tokenData.deviceToken}: ${error.message}`, error); throw error; } } @@ -322,4 +321,34 @@ export class PushTokenService { this.logger.error(`Failed to update last used time: ${error.message}`, error); } } + + /** + * 直接停用设备令牌(不需要用户ID) + */ + async deactivateToken(deviceToken: string): Promise { + try { + this.logger.log(`Deactivating push token: ${deviceToken}`); + + const token = await this.pushTokenModel.findOne({ + where: { + deviceToken, + isActive: true, + }, + }); + + if (!token) { + this.logger.warn(`Device token not found or already inactive: ${deviceToken}`); + return; + } + + await token.update({ + isActive: false, + }); + + this.logger.log(`Successfully deactivated push token: ${deviceToken}`); + } catch (error) { + this.logger.error(`Failed to deactivate push token: ${deviceToken}: ${error.message}`, error); + throw error; + } + } } \ No newline at end of file From 5c2c9dfae84e49ce52e2ada7bf25c8cba3f12f87 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 16 Oct 2025 10:03:22 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(diet-records):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=90=A5=E5=85=BB=E6=88=90=E5=88=86=E8=A1=A8=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加营养成分表图片识别API接口,支持通过AI模型分析食物营养成分 - 新增NutritionAnalysisService服务,集成GLM-4.5V和Qwen VL视觉模型 - 实现营养成分提取和健康建议生成功能 - 添加完整的API文档和TypeScript类型定义 - 支持多种营养素类型识别,包括热量、蛋白质、脂肪等20+种营养素 --- .kilocode/rules/rule.md | 3 +- docs/nutrition-analysis-api.md | 295 ++++++++++++++++++ src/diet-records/diet-records.controller.ts | 57 ++++ src/diet-records/diet-records.module.ts | 5 +- .../dto/nutrition-analysis-request.dto.ts | 13 + .../dto/nutrition-analysis.dto.ts | 32 ++ .../services/nutrition-analysis.service.ts | 282 +++++++++++++++++ 7 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 docs/nutrition-analysis-api.md create mode 100644 src/diet-records/dto/nutrition-analysis-request.dto.ts create mode 100644 src/diet-records/dto/nutrition-analysis.dto.ts create mode 100644 src/diet-records/services/nutrition-analysis.service.ts diff --git a/.kilocode/rules/rule.md b/.kilocode/rules/rule.md index e678a2f..697eed4 100644 --- a/.kilocode/rules/rule.md +++ b/.kilocode/rules/rule.md @@ -1,8 +1,9 @@ # rule.md -这是一个 nodejs 基于 nestjs 框架的项目 +你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关 ## 指导原则 - 不要随意新增 markdown 文档 - 代码提交 message 用中文 +- 注意代码的可读性、架构实现要清晰 diff --git a/docs/nutrition-analysis-api.md b/docs/nutrition-analysis-api.md new file mode 100644 index 0000000..69f9020 --- /dev/null +++ b/docs/nutrition-analysis-api.md @@ -0,0 +1,295 @@ +# 营养成分表分析 API 文档 + +## 接口概述 + +本接口用于分析食物营养成分表图片,通过AI大模型智能识别图片中的营养成分信息,并为每个营养素提供详细的健康建议。 + +## 接口信息 + +- **接口地址**: `POST /diet-records/analyze-nutrition-image` +- **请求方式**: POST +- **内容类型**: `application/json` +- **认证方式**: Bearer Token (JWT) + +## 请求参数 + +### Headers + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| Authorization | string | 是 | JWT认证令牌,格式:`Bearer {token}` | +| Content-Type | string | 是 | 固定值:`application/json` | + +### Body 参数 + +| 参数名 | 类型 | 必填 | 说明 | 示例 | +|--------|------|------|------|------| +| imageUrl | string | 是 | 营养成分表图片的URL地址 | `https://example.com/nutrition-label.jpg` | + +#### 请求示例 + +```json +{ + "imageUrl": "https://example.com/nutrition-label.jpg" +} +``` + +## 响应格式 + +### 成功响应 + +```json +{ + "success": true, + "data": [ + { + "key": "energy_kcal", + "name": "热量", + "value": "840千焦", + "analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。" + }, + { + "key": "protein", + "name": "蛋白质", + "value": "12.5g", + "analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。" + }, + { + "key": "fat", + "name": "脂肪", + "value": "6.8g", + "analysis": "6.8克脂肪含量适中,主要包含不饱和脂肪酸,有助于维持正常的生理功能。" + }, + { + "key": "carbohydrate", + "name": "碳水化合物", + "value": "28.5g", + "analysis": "28.5克碳水化合物提供主要能量来源,建议搭配运动以充分利用能量。" + }, + { + "key": "sodium", + "name": "钠", + "value": "480mg", + "analysis": "480毫克钠含量适中,约占成人每日推荐摄入量的20%,高血压患者需注意控制总钠摄入。" + } + ] +} +``` + +### 错误响应 + +```json +{ + "success": false, + "data": [], + "message": "错误描述信息" +} +``` + +## 响应字段说明 + +### 通用字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| success | boolean | 操作是否成功 | +| data | array | 营养成分分析结果数组 | +| message | string | 错误信息(仅在失败时返回) | + +### 营养成分项字段 (data数组中的对象) + +| 字段名 | 类型 | 说明 | 示例 | +|--------|------|------|------| +| key | string | 营养素的唯一标识符 | `energy_kcal` | +| name | string | 营养素的中文名称 | `热量` | +| value | string | 从图片中识别的原始值和单位 | `840千焦` | +| analysis | string | 针对该营养素的详细健康建议 | `840千焦约等于201卡路里...` | + +## 支持的营养素类型 + +| 营养素 | key值 | 中文名称 | +|--------|-------|----------| +| 热量/能量 | energy_kcal | 热量 | +| 蛋白质 | protein | 蛋白质 | +| 脂肪 | fat | 脂肪 | +| 碳水化合物 | carbohydrate | 碳水化合物 | +| 膳食纤维 | fiber | 膳食纤维 | +| 钠 | sodium | 钠 | +| 钙 | calcium | 钙 | +| 铁 | iron | 铁 | +| 锌 | zinc | 锌 | +| 维生素C | vitamin_c | 维生素C | +| 维生素A | vitamin_a | 维生素A | +| 维生素D | vitamin_d | 维生素D | +| 维生素E | vitamin_e | 维生素E | +| 维生素B1 | vitamin_b1 | 维生素B1 | +| 维生素B2 | vitamin_b2 | 维生素B2 | +| 维生素B6 | vitamin_b6 | 维生素B6 | +| 维生素B12 | vitamin_b12 | 维生素B12 | +| 叶酸 | folic_acid | 叶酸 | +| 胆固醇 | cholesterol | 胆固醇 | +| 饱和脂肪 | saturated_fat | 饱和脂肪 | +| 反式脂肪 | trans_fat | 反式脂肪 | +| 糖 | sugar | 糖 | + +## 错误码说明 + +| HTTP状态码 | 错误信息 | 说明 | +|------------|----------|------| +| 400 | 请提供图片URL | 请求体中缺少imageUrl参数 | +| 400 | 图片URL格式不正确 | 提供的URL格式无效 | +| 401 | 未授权访问 | 缺少或无效的JWT令牌 | +| 500 | 营养成分表分析失败,请稍后重试 | AI模型调用失败或服务器内部错误 | +| 500 | 图片中未检测到有效的营养成分表信息 | 图片中未识别到营养成分表 | + +## 使用注意事项 + +### 图片要求 + +1. **图片格式**: 支持 JPG、PNG、WebP 格式 +2. **图片内容**: 必须包含清晰的营养成分表 +3. **图片质量**: 建议使用高清、无模糊、光线充足的图片 +4. **URL要求**: 图片URL必须是公网可访问的地址 + +### 最佳实践 + +1. **URL有效性**: 确保提供的图片URL在分析期间保持可访问 +2. **图片预处理**: 建议在客户端对图片进行适当的裁剪,突出营养成分表部分 +3. **错误处理**: 客户端应妥善处理各种错误情况,提供友好的用户提示 +4. **重试机制**: 对于网络或服务器错误,建议实现适当的重试机制 + +### 限制说明 + +1. **调用频率**: 建议客户端控制调用频率,避免过于频繁的请求 +2. **图片大小**: 虽然不直接限制图片大小,但过大的图片可能影响处理速度 +3. **并发限制**: 服务端可能有并发请求限制,建议客户端实现队列机制 + +## 客户端集成示例 + +### JavaScript/TypeScript 示例 + +```typescript +interface NutritionAnalysisRequest { + imageUrl: string; +} + +interface NutritionAnalysisItem { + key: string; + name: string; + value: string; + analysis: string; +} + +interface NutritionAnalysisResponse { + success: boolean; + data: NutritionAnalysisItem[]; + message?: string; +} + +async function analyzeNutritionImage( + imageUrl: string, + token: string +): Promise { + try { + const response = await fetch('/diet-records/analyze-nutrition-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ imageUrl }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || '请求失败'); + } + + return result; + } catch (error) { + console.error('营养成分分析失败:', error); + throw error; + } +} + +// 使用示例 +const token = 'your-jwt-token'; +const imageUrl = 'https://example.com/nutrition-label.jpg'; + +analyzeNutritionImage(imageUrl, token) + .then(result => { + if (result.success) { + console.log('识别到营养素数量:', result.data.length); + result.data.forEach(item => { + console.log(`${item.name}: ${item.value}`); + console.log(`建议: ${item.analysis}`); + }); + } else { + console.error('分析失败:', result.message); + } + }) + .catch(error => { + console.error('请求异常:', error); + }); +``` + +### Swift 示例 + +```swift +struct NutritionAnalysisRequest: Codable { + let imageUrl: String +} + +struct NutritionAnalysisItem: Codable { + let key: String + let name: String + let value: String + let analysis: String +} + +struct NutritionAnalysisResponse: Codable { + let success: Bool + let data: [NutritionAnalysisItem] + let message: String? +} + +class NutritionAnalysisService { + func analyzeNutritionImage(imageUrl: String, token: String) async throws -> NutritionAnalysisResponse { + guard let url = URL(string: "/diet-records/analyze-nutrition-image") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let requestBody = NutritionAnalysisRequest(imageUrl: imageUrl) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard 200...299 ~= httpResponse.statusCode else { + throw NSError(domain: "APIError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error"]) + } + + let result = try JSONDecoder().decode(NutritionAnalysisResponse.self, from: data) + return result + } +} +``` + +## 更新日志 + +| 版本 | 日期 | 更新内容 | +|------|------|----------| +| 1.0.0 | 2024-10-16 | 初始版本,支持营养成分表图片分析功能 | + +## 技术支持 + +如有技术问题或集成困难,请联系开发团队获取支持。 \ No newline at end of file diff --git a/src/diet-records/diet-records.controller.ts b/src/diet-records/diet-records.controller.ts index c30e9f8..b5a64d6 100644 --- a/src/diet-records/diet-records.controller.ts +++ b/src/diet-records/diet-records.controller.ts @@ -15,7 +15,10 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger'; import { DietRecordsService } from './diet-records.service'; +import { NutritionAnalysisService } from './services/nutrition-analysis.service'; import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto'; +import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto'; +import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.service'; @@ -27,6 +30,7 @@ export class DietRecordsController { constructor( private readonly dietRecordsService: DietRecordsService, + private readonly nutritionAnalysisService: NutritionAnalysisService, ) { } /** @@ -161,4 +165,57 @@ export class DietRecordsController { requestDto.mealType ); } + + /** + * 分析食物营养成分表图片 + */ + @UseGuards(JwtAuthGuard) + @Post('analyze-nutrition-image') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '分析食物营养成分表图片' }) + @ApiBody({ type: NutritionAnalysisRequestDto }) + @ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 401, description: '未授权访问' }) + @ApiResponse({ status: 500, description: '服务器内部错误' }) + async analyzeNutritionImage( + @Body() requestDto: NutritionAnalysisRequestDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`); + + if (!requestDto.imageUrl) { + return { + success: false, + data: [], + message: '请提供图片URL' + }; + } + + // 验证URL格式 + try { + new URL(requestDto.imageUrl); + } catch (error) { + return { + success: false, + data: [], + message: '图片URL格式不正确' + }; + } + + try { + const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl); + + this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`); + + return result; + } catch (error) { + this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`); + return { + success: false, + data: [], + message: '营养成分表分析失败,请稍后重试' + }; + } + } } \ No newline at end of file diff --git a/src/diet-records/diet-records.module.ts b/src/diet-records/diet-records.module.ts index 5f526b1..729e83a 100644 --- a/src/diet-records/diet-records.module.ts +++ b/src/diet-records/diet-records.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { DietRecordsController } from './diet-records.controller'; import { DietRecordsService } from './diet-records.service'; +import { NutritionAnalysisService } from './services/nutrition-analysis.service'; import { UserDietHistory } from '../users/models/user-diet-history.model'; import { ActivityLog } from '../activity-logs/models/activity-log.model'; import { UsersModule } from '../users/users.module'; @@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module'; forwardRef(() => AiCoachModule), ], controllers: [DietRecordsController], - providers: [DietRecordsService], - exports: [DietRecordsService], + providers: [DietRecordsService, NutritionAnalysisService], + exports: [DietRecordsService, NutritionAnalysisService], }) export class DietRecordsModule { } \ No newline at end of file diff --git a/src/diet-records/dto/nutrition-analysis-request.dto.ts b/src/diet-records/dto/nutrition-analysis-request.dto.ts new file mode 100644 index 0000000..d2d8e90 --- /dev/null +++ b/src/diet-records/dto/nutrition-analysis-request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 营养成分分析请求DTO + */ +export class NutritionAnalysisRequestDto { + @ApiProperty({ + description: '营养成分表图片URL', + example: 'https://example.com/nutrition-label.jpg', + required: true + }) + imageUrl: string; +} \ No newline at end of file diff --git a/src/diet-records/dto/nutrition-analysis.dto.ts b/src/diet-records/dto/nutrition-analysis.dto.ts new file mode 100644 index 0000000..137ea6c --- /dev/null +++ b/src/diet-records/dto/nutrition-analysis.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 营养成分分析结果项 + */ +export class NutritionAnalysisItemDto { + @ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' }) + key: string; + + @ApiProperty({ description: '营养素的中文名称', example: '热量' }) + name: string; + + @ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' }) + value: string; + + @ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。' }) + analysis: string; +} + +/** + * 营养成分分析响应DTO + */ +export class NutritionAnalysisResponseDto { + @ApiProperty({ description: '操作是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] }) + data: NutritionAnalysisItemDto[]; + + @ApiProperty({ description: '响应消息', required: false }) + message?: string; +} \ No newline at end of file diff --git a/src/diet-records/services/nutrition-analysis.service.ts b/src/diet-records/services/nutrition-analysis.service.ts new file mode 100644 index 0000000..5299eb7 --- /dev/null +++ b/src/diet-records/services/nutrition-analysis.service.ts @@ -0,0 +1,282 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OpenAI } from 'openai'; + +/** + * 营养成分分析结果接口 + */ +export interface NutritionAnalysisResult { + key: string; // 营养素的唯一标识,如 energy_kcal + name: string; // 营养素的中文名称,如"热量" + value: string; // 从图片中识别的原始值和单位,如"840千焦" + analysis: string; // 针对该营养素的详细健康建议 +} + +/** + * 营养成分分析响应接口 + */ +export interface NutritionAnalysisResponse { + success: boolean; + data: NutritionAnalysisResult[]; + message?: string; +} + +/** + * 营养成分分析服务 + * 负责处理食物营养成分表的AI分析 + * + * 支持多种AI模型: + * - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm + * - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认) + */ +@Injectable() +export class NutritionAnalysisService { + private readonly logger = new Logger(NutritionAnalysisService.name); + private readonly client: OpenAI; + private readonly visionModel: string; + private readonly apiProvider: string; + + constructor(private readonly configService: ConfigService) { + // Support both GLM-4.5V and DashScope (Qwen) models + this.apiProvider = this.configService.get('AI_VISION_PROVIDER') || 'dashscope'; + + if (this.apiProvider === 'glm') { + // GLM-4.5V Configuration + const glmApiKey = this.configService.get('GLM_API_KEY'); + const glmBaseURL = this.configService.get('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4'; + + this.client = new OpenAI({ + apiKey: glmApiKey, + baseURL: glmBaseURL, + }); + + this.visionModel = this.configService.get('GLM_VISION_MODEL') || 'glm-4v-plus'; + } else { + // DashScope Configuration (default) + const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143'; + const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + + this.client = new OpenAI({ + apiKey: dashScopeApiKey, + baseURL, + }); + + this.visionModel = this.configService.get('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max'; + } + } + + /** + * 分析食物营养成分表图片 + * @param imageUrl 图片URL + * @returns 营养成分分析结果 + */ + async analyzeNutritionImage(imageUrl: string): Promise { + try { + this.logger.log(`开始分析营养成分表图片: ${imageUrl}`); + + const prompt = this.buildNutritionAnalysisPrompt(); + + const completion = await this.makeVisionApiCall(prompt, [imageUrl]); + + const rawResult = completion.choices?.[0]?.message?.content || '[]'; + this.logger.log(`营养成分分析原始结果: ${rawResult}`); + + return this.parseNutritionAnalysisResult(rawResult); + } catch (error) { + this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`); + return { + success: false, + data: [], + message: '营养成分表分析失败,请稍后重试' + }; + } + } + + /** + * 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope + * @param prompt 提示文本 + * @param imageUrls 图片URL数组 + * @returns API响应 + */ + private async makeVisionApiCall(prompt: string, imageUrls: string[]) { + const baseParams = { + model: this.visionModel, + temperature: 0.3, + response_format: { type: 'json_object' } as any, + }; + + // 处理图片URL + const processedImages = imageUrls.map((imageUrl) => ({ + type: 'image_url', + image_url: { url: imageUrl } as any, + })); + + if (this.apiProvider === 'glm') { + // GLM-4.5V format + return await this.client.chat.completions.create({ + ...baseParams, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + ...processedImages, + ] as any, + }, + ], + } as any); + } else { + // DashScope format (default) + return await this.client.chat.completions.create({ + ...baseParams, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + ...processedImages, + ] as any, + }, + ], + }); + } + } + + /** + * 构建营养成分分析提示 + * @returns 提示文本 + */ + private buildNutritionAnalysisPrompt(): string { + return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。 + +**任务要求:** +1. 识别图片中的营养成分表,提取所有可见的营养素信息 +2. 为每个营养素提供详细的健康建议和分析 +3. 返回严格的JSON数组格式,不包含任何额外的解释或对话文本 + +**输出格式要求:** +请严格按照以下JSON数组格式返回,每个对象包含四个字段: +[ + { + "key": "energy_kcal", + "name": "热量", + "value": "840千焦", + "analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。" + }, + { + "key": "protein", + "name": "蛋白质", + "value": "12.5g", + "analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。" + } +] + +**营养素标识符对照表:** +- 热量/能量: energy_kcal +- 蛋白质: protein +- 脂肪: fat +- 碳水化合物: carbohydrate +- 膳食纤维: fiber +- 钠: sodium +- 钙: calcium +- 铁: iron +- 锌: zinc +- 维生素C: vitamin_c +- 维生素A: vitamin_a +- 维生素D: vitamin_d +- 维生素E: vitamin_e +- 维生素B1: vitamin_b1 +- 维生素B2: vitamin_b2 +- 维生素B6: vitamin_b6 +- 维生素B12: vitamin_b12 +- 叶酸: folic_acid +- 胆固醇: cholesterol +- 饱和脂肪: saturated_fat +- 反式脂肪: trans_fat +- 糖: sugar +- 其他营养素: other_nutrient + +**分析要求:** +1. 如果图片中没有营养成分表,返回空数组 [] +2. 为每个识别到的营养素提供具体的健康建议 +3. 建议应包含营养素的作用、摄入量参考和健康影响 +4. 数值分析要准确,建议要专业且实用 +5. 只返回JSON数组,不要包含任何其他文本 + +**重要提醒:** +- 严格按照JSON数组格式返回 +- 不要添加任何解释性文字或对话内容 +- 确保JSON格式正确,可以被直接解析`; + } + + /** + * 解析营养成分分析结果 + * @param rawResult 原始结果字符串 + * @returns 解析后的分析结果 + */ + private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse { + try { + // 尝试解析JSON + let parsedResult: any; + try { + parsedResult = JSON.parse(rawResult); + } catch (parseError) { + this.logger.error(`营养成分分析JSON解析失败: ${parseError}`); + this.logger.error(`原始结果: ${rawResult}`); + return { + success: false, + data: [], + message: '营养成分表解析失败,无法识别有效的营养信息' + }; + } + + // 确保结果是数组 + if (!Array.isArray(parsedResult)) { + this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`); + return { + success: false, + data: [], + message: '营养成分表格式错误,无法识别有效的营养信息' + }; + } + + // 验证和标准化每个营养素项 + const nutritionData: NutritionAnalysisResult[] = []; + + for (const item of parsedResult) { + if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) { + nutritionData.push({ + key: String(item.key).trim(), + name: String(item.name).trim(), + value: String(item.value).trim(), + analysis: String(item.analysis).trim() + }); + } else { + this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`); + } + } + + if (nutritionData.length === 0) { + return { + success: false, + data: [], + message: '图片中未检测到有效的营养成分表信息' + }; + } + + this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`); + + return { + success: true, + data: nutritionData + }; + } catch (error) { + this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`); + return { + success: false, + data: [], + message: '营养成分表处理失败,请稍后重试' + }; + } + } +} \ No newline at end of file