feat: 支持 push

This commit is contained in:
richarjiang
2025-10-11 17:38:04 +08:00
parent 999fc7f793
commit 305a969912
30 changed files with 4582 additions and 1 deletions

View File

@@ -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调用来发送各种类型的推送通知提升用户体验和业务指标。

View File

@@ -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 运维监控
- 推送服务健康检查
- 性能指标监控
- 告警机制设置

View File

@@ -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 <access_token>
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 <access_token>
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 <access_token>
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 <access_token>
```
#### 创建推送模板
```bash
POST /api/push-notifications/templates
Authorization: Bearer <access_token>
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 <access_token>
Content-Type: application/json
{
"title": "更新后的标题",
"body": "更新后的内容:{{userName}}{{reminderContent}}",
"isActive": true
}
```
#### 删除推送模板
```bash
DELETE /api/push-notifications/templates/:id
Authorization: Bearer <access_token>
```
## 代码示例
### 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<void> {
// 使用模板发送推送
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<void> {
// 直接发送推送
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推送功能已完全集成到系统中提供了完整的推送令牌管理、消息发送和模板系统。通过遵循本指南您可以轻松地在应用中实现各种推送场景提升用户体验和参与度。
如有任何问题或需要进一步的技术支持,请参考相关文档或联系开发团队。

View File

@@ -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<PushResponseDto>
// 批量发送推送
async sendBatchNotifications(userIds: string[], notification: PushNotificationDto): Promise<BatchPushResponseDto>
// 使用模板发送推送
async sendNotificationByTemplate(userId: string, templateKey: string, data: any): Promise<PushResponseDto>
// 发送静默推送
async sendSilentNotification(userId: string, payload: any): Promise<PushResponseDto>
}
```
### 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<apn.Results>
// 批量发送通知
async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise<apn.Results>
// 管理推送通道
async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise<any>
// 广播实时活动通知
async broadcast(notification: apn.Notification, bundleId: string): Promise<any>
// 关闭连接
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<UserPushToken>
// 更新设备令牌
async updateToken(userId: string, tokenData: UpdateDeviceTokenDto): Promise<UserPushToken>
// 注销设备令牌
async unregisterToken(userId: string, deviceToken: string): Promise<void>
// 获取用户的所有有效令牌
async getActiveTokens(userId: string): Promise<UserPushToken[]>
// 清理无效令牌
async cleanupInvalidTokens(): Promise<number>
// 验证令牌有效性
async validateToken(deviceToken: string): Promise<boolean>
}
```
### 2.4 PushTemplateService
```typescript
@Injectable()
export class PushTemplateService {
constructor(
@InjectModel(PushTemplate) private readonly templateModel: typeof PushTemplate,
) {}
// 创建推送模板
async createTemplate(templateData: CreatePushTemplateDto): Promise<PushTemplate>
// 更新推送模板
async updateTemplate(id: string, templateData: UpdatePushTemplateDto): Promise<PushTemplate>
// 删除推送模板
async deleteTemplate(id: string): Promise<void>
// 获取模板
async getTemplate(templateKey: string): Promise<PushTemplate>
// 获取所有模板
async getAllTemplates(): Promise<PushTemplate[]>
// 渲染模板
async renderTemplate(templateKey: string, data: any): Promise<RenderedTemplate>
}
```
### 2.5 PushMessageService
```typescript
@Injectable()
export class PushMessageService {
constructor(
@InjectModel(PushMessage) private readonly messageModel: typeof PushMessage,
) {}
// 创建推送消息记录
async createMessage(messageData: CreatePushMessageDto): Promise<PushMessage>
// 更新消息状态
async updateMessageStatus(id: string, status: PushMessageStatus, response?: any): Promise<void>
// 获取消息历史
async getMessageHistory(userId: string, options: QueryOptions): Promise<PushMessage[]>
// 获取消息统计
async getMessageStats(userId?: string, timeRange?: TimeRange): Promise<PushStats>
// 清理过期消息
async cleanupExpiredMessages(): Promise<number>
}
```
## 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<ResponseDto> {
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<ResponseDto> {
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<ResponseDto> {
return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken);
}
// 发送推送通知
@Post('send')
@ApiOperation({ summary: '发送推送通知' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotification(sendNotificationDto);
}
// 使用模板发送推送
@Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto);
}
// 批量发送推送
@Post('send-batch')
@ApiOperation({ summary: '批量发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto,
): Promise<BatchPushResponseDto> {
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<PushTemplate[]> {
return this.pushTemplateService.getAllTemplates();
}
// 获取单个模板
@Get(':templateKey')
@ApiOperation({ summary: '获取推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: PushTemplate })
async getTemplate(
@Param('templateKey') templateKey: string,
): Promise<PushTemplate> {
return this.pushTemplateService.getTemplate(templateKey);
}
// 创建模板
@Post()
@ApiOperation({ summary: '创建推送模板' })
@ApiResponse({ status: 201, description: '创建成功', type: PushTemplate })
async createTemplate(
@Body() createTemplateDto: CreatePushTemplateDto,
): Promise<PushTemplate> {
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<PushTemplate> {
return this.pushTemplateService.updateTemplate(id, updateTemplateDto);
}
// 删除模板
@Delete(':id')
@ApiOperation({ summary: '删除推送模板' })
@ApiResponse({ status: 200, description: '删除成功' })
async deleteTemplate(@Param('id') id: string): Promise<void> {
return this.pushTemplateService.deleteTemplate(id);
}
}
```
## 5. 接口使用示例
### 5.1 注册设备令牌
```bash
POST /api/push-notifications/register-token
Authorization: Bearer <access_token>
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<Response>();
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 缓存策略
- 缓存用户设备令牌减少数据库查询
- 缓存推送模板提高渲染性能
- 实现分布式缓存支持集群部署

View File

@@ -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"
}
}
}

View File

@@ -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);

View File

@@ -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],

View File

@@ -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<string>('APNS_KEY_ID');
const teamId = this.configService.get<string>('APNS_TEAM_ID');
const keyPath = this.configService.get<string>('APNS_KEY_PATH');
const bundleId = this.configService.get<string>('APNS_BUNDLE_ID');
const environment = this.configService.get<string>('APNS_ENVIRONMENT', 'sandbox');
const clientCount = this.configService.get<number>('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<number>('APNS_CONNECTION_RETRY_LIMIT', 3),
heartBeat: this.configService.get<number>('APNS_HEARTBEAT', 60000),
requestTimeout: this.configService.get<number>('APNS_REQUEST_TIMEOUT', 5000),
};
}
/**
* 初始化APNs连接
*/
private async initializeProvider(): Promise<void> {
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<apn.Results> {
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<apn.Results> {
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<any> {
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<any> {
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',
};
}
}

View File

@@ -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;
}

View File

@@ -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;
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
export enum DeviceType {
IOS = 'IOS',
ANDROID = 'ANDROID',
}

View File

@@ -0,0 +1,6 @@
export enum PushMessageStatus {
PENDING = 'PENDING',
SENT = 'SENT',
FAILED = 'FAILED',
EXPIRED = 'EXPIRED',
}

View File

@@ -0,0 +1,6 @@
export enum PushType {
ALERT = 'ALERT',
BACKGROUND = 'BACKGROUND',
VOIP = 'VOIP',
LIVEACTIVITY = 'LIVEACTIVITY',
}

View File

@@ -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;
}

View File

@@ -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<string, number>;
}
export interface QueryOptions {
limit?: number;
offset?: number;
startDate?: Date;
endDate?: Date;
status?: string;
messageType?: string;
}
export interface TimeRange {
startDate: Date;
endDate: Date;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<PushMessage> {
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<void> {
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<void> {
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<PushMessage[]> {
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<PushStats> {
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<string, number> = {};
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<number> {
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<PushMessage[]> {
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<PushMessage[]> {
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<number> {
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;
}
}
}

View File

@@ -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<RegisterTokenResponseDto> {
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<UpdateTokenResponseDto> {
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<UnregisterTokenResponseDto> {
return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken);
}
@Post('send')
@ApiOperation({ summary: '发送推送通知' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotification(sendNotificationDto);
}
@Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto);
}
@Post('send-batch')
@ApiOperation({ summary: '批量发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto,
): Promise<BatchPushResponseDto> {
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<PushResponseDto> {
return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload);
}
}

View File

@@ -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 { }

View File

@@ -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<string>('APNS_BUNDLE_ID') || '';
}
/**
* 发送单个推送通知
*/
async sendNotification(notificationData: SendPushNotificationDto): Promise<PushResponseDto> {
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<PushResponseDto> {
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<BatchPushResponseDto> {
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<PushResponseDto> {
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<any> {
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<any> {
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<any> {
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,
},
};
}
}
}

View File

@@ -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<PushTemplate[]> {
return this.pushTemplateService.getAllTemplates();
}
@Get('active')
@ApiOperation({ summary: '获取所有活跃推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] })
async getActiveTemplates(): Promise<PushTemplate[]> {
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<PushTemplate> {
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<PushTemplate> {
return this.pushTemplateService.getTemplateById(id);
}
@Post()
@ApiOperation({ summary: '创建推送模板' })
@ApiResponse({ status: 201, description: '创建成功', type: PushTemplate })
async createTemplate(
@Body() createTemplateDto: CreatePushTemplateDto,
): Promise<PushTemplate> {
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<PushTemplate> {
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<void> {
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<PushTemplate> {
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 };
}
}

View File

@@ -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<PushTemplate> {
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<PushTemplate> {
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<void> {
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<PushTemplate> {
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<PushTemplate> {
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<PushTemplate[]> {
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<PushTemplate[]> {
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<RenderedTemplate> {
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<PushTemplate> {
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)]; // 去重
}
}

View File

@@ -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<UserPushToken> {
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<UserPushToken> {
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<void> {
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<UserPushToken[]> {
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<UserPushToken[]> {
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<boolean> {
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<number> {
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<string | null> {
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<Map<string, string[]>> {
try {
const tokens = await this.pushTokenModel.findAll({
where: {
userId: {
[Op.in]: userIds,
},
isActive: true,
},
});
const userTokensMap = new Map<string, string[]>();
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<void> {
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);
}
}
}