Merge branch 'feature/push'
This commit is contained in:
9
.kilocode/rules/rule.md
Normal file
9
.kilocode/rules/rule.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# rule.md
|
||||
|
||||
你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 不要随意新增 markdown 文档
|
||||
- 代码提交 message 用中文
|
||||
- 注意代码的可读性、架构实现要清晰
|
||||
6
SubscriptionKey_3YKHQZ374P.p8
Normal file
6
SubscriptionKey_3YKHQZ374P.p8
Normal file
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V
|
||||
UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH
|
||||
GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F
|
||||
Db0U/xrS
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,6 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW
|
||||
4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT
|
||||
FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb
|
||||
8U5D2WWu
|
||||
-----END PRIVATE KEY-----
|
||||
288
docs/ios-push-implementation-plan.md
Normal file
288
docs/ios-push-implementation-plan.md
Normal 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调用来发送各种类型的推送通知,提升用户体验和业务指标。
|
||||
265
docs/ios-push-notification-design.md
Normal file
265
docs/ios-push-notification-design.md
Normal 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 运维监控
|
||||
- 推送服务健康检查
|
||||
- 性能指标监控
|
||||
- 告警机制设置
|
||||
295
docs/nutrition-analysis-api.md
Normal file
295
docs/nutrition-analysis-api.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# 营养成分表分析 API 文档
|
||||
|
||||
## 接口概述
|
||||
|
||||
本接口用于分析食物营养成分表图片,通过AI大模型智能识别图片中的营养成分信息,并为每个营养素提供详细的健康建议。
|
||||
|
||||
## 接口信息
|
||||
|
||||
- **接口地址**: `POST /diet-records/analyze-nutrition-image`
|
||||
- **请求方式**: POST
|
||||
- **内容类型**: `application/json`
|
||||
- **认证方式**: Bearer Token (JWT)
|
||||
|
||||
## 请求参数
|
||||
|
||||
### Headers
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| Authorization | string | 是 | JWT认证令牌,格式:`Bearer {token}` |
|
||||
| Content-Type | string | 是 | 固定值:`application/json` |
|
||||
|
||||
### Body 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|
||||
|--------|------|------|------|------|
|
||||
| imageUrl | string | 是 | 营养成分表图片的URL地址 | `https://example.com/nutrition-label.jpg` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"imageUrl": "https://example.com/nutrition-label.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"key": "energy_kcal",
|
||||
"name": "热量",
|
||||
"value": "840千焦",
|
||||
"analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。"
|
||||
},
|
||||
{
|
||||
"key": "protein",
|
||||
"name": "蛋白质",
|
||||
"value": "12.5g",
|
||||
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
|
||||
},
|
||||
{
|
||||
"key": "fat",
|
||||
"name": "脂肪",
|
||||
"value": "6.8g",
|
||||
"analysis": "6.8克脂肪含量适中,主要包含不饱和脂肪酸,有助于维持正常的生理功能。"
|
||||
},
|
||||
{
|
||||
"key": "carbohydrate",
|
||||
"name": "碳水化合物",
|
||||
"value": "28.5g",
|
||||
"analysis": "28.5克碳水化合物提供主要能量来源,建议搭配运动以充分利用能量。"
|
||||
},
|
||||
{
|
||||
"key": "sodium",
|
||||
"name": "钠",
|
||||
"value": "480mg",
|
||||
"analysis": "480毫克钠含量适中,约占成人每日推荐摄入量的20%,高血压患者需注意控制总钠摄入。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": [],
|
||||
"message": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
## 响应字段说明
|
||||
|
||||
### 通用字段
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| success | boolean | 操作是否成功 |
|
||||
| data | array | 营养成分分析结果数组 |
|
||||
| message | string | 错误信息(仅在失败时返回) |
|
||||
|
||||
### 营养成分项字段 (data数组中的对象)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 示例 |
|
||||
|--------|------|------|------|
|
||||
| key | string | 营养素的唯一标识符 | `energy_kcal` |
|
||||
| name | string | 营养素的中文名称 | `热量` |
|
||||
| value | string | 从图片中识别的原始值和单位 | `840千焦` |
|
||||
| analysis | string | 针对该营养素的详细健康建议 | `840千焦约等于201卡路里...` |
|
||||
|
||||
## 支持的营养素类型
|
||||
|
||||
| 营养素 | key值 | 中文名称 |
|
||||
|--------|-------|----------|
|
||||
| 热量/能量 | energy_kcal | 热量 |
|
||||
| 蛋白质 | protein | 蛋白质 |
|
||||
| 脂肪 | fat | 脂肪 |
|
||||
| 碳水化合物 | carbohydrate | 碳水化合物 |
|
||||
| 膳食纤维 | fiber | 膳食纤维 |
|
||||
| 钠 | sodium | 钠 |
|
||||
| 钙 | calcium | 钙 |
|
||||
| 铁 | iron | 铁 |
|
||||
| 锌 | zinc | 锌 |
|
||||
| 维生素C | vitamin_c | 维生素C |
|
||||
| 维生素A | vitamin_a | 维生素A |
|
||||
| 维生素D | vitamin_d | 维生素D |
|
||||
| 维生素E | vitamin_e | 维生素E |
|
||||
| 维生素B1 | vitamin_b1 | 维生素B1 |
|
||||
| 维生素B2 | vitamin_b2 | 维生素B2 |
|
||||
| 维生素B6 | vitamin_b6 | 维生素B6 |
|
||||
| 维生素B12 | vitamin_b12 | 维生素B12 |
|
||||
| 叶酸 | folic_acid | 叶酸 |
|
||||
| 胆固醇 | cholesterol | 胆固醇 |
|
||||
| 饱和脂肪 | saturated_fat | 饱和脂肪 |
|
||||
| 反式脂肪 | trans_fat | 反式脂肪 |
|
||||
| 糖 | sugar | 糖 |
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 错误信息 | 说明 |
|
||||
|------------|----------|------|
|
||||
| 400 | 请提供图片URL | 请求体中缺少imageUrl参数 |
|
||||
| 400 | 图片URL格式不正确 | 提供的URL格式无效 |
|
||||
| 401 | 未授权访问 | 缺少或无效的JWT令牌 |
|
||||
| 500 | 营养成分表分析失败,请稍后重试 | AI模型调用失败或服务器内部错误 |
|
||||
| 500 | 图片中未检测到有效的营养成分表信息 | 图片中未识别到营养成分表 |
|
||||
|
||||
## 使用注意事项
|
||||
|
||||
### 图片要求
|
||||
|
||||
1. **图片格式**: 支持 JPG、PNG、WebP 格式
|
||||
2. **图片内容**: 必须包含清晰的营养成分表
|
||||
3. **图片质量**: 建议使用高清、无模糊、光线充足的图片
|
||||
4. **URL要求**: 图片URL必须是公网可访问的地址
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **URL有效性**: 确保提供的图片URL在分析期间保持可访问
|
||||
2. **图片预处理**: 建议在客户端对图片进行适当的裁剪,突出营养成分表部分
|
||||
3. **错误处理**: 客户端应妥善处理各种错误情况,提供友好的用户提示
|
||||
4. **重试机制**: 对于网络或服务器错误,建议实现适当的重试机制
|
||||
|
||||
### 限制说明
|
||||
|
||||
1. **调用频率**: 建议客户端控制调用频率,避免过于频繁的请求
|
||||
2. **图片大小**: 虽然不直接限制图片大小,但过大的图片可能影响处理速度
|
||||
3. **并发限制**: 服务端可能有并发请求限制,建议客户端实现队列机制
|
||||
|
||||
## 客户端集成示例
|
||||
|
||||
### JavaScript/TypeScript 示例
|
||||
|
||||
```typescript
|
||||
interface NutritionAnalysisRequest {
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface NutritionAnalysisItem {
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
interface NutritionAnalysisResponse {
|
||||
success: boolean;
|
||||
data: NutritionAnalysisItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function analyzeNutritionImage(
|
||||
imageUrl: string,
|
||||
token: string
|
||||
): Promise<NutritionAnalysisResponse> {
|
||||
try {
|
||||
const response = await fetch('/diet-records/analyze-nutrition-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ imageUrl })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '请求失败');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('营养成分分析失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const token = 'your-jwt-token';
|
||||
const imageUrl = 'https://example.com/nutrition-label.jpg';
|
||||
|
||||
analyzeNutritionImage(imageUrl, token)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
console.log('识别到营养素数量:', result.data.length);
|
||||
result.data.forEach(item => {
|
||||
console.log(`${item.name}: ${item.value}`);
|
||||
console.log(`建议: ${item.analysis}`);
|
||||
});
|
||||
} else {
|
||||
console.error('分析失败:', result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('请求异常:', error);
|
||||
});
|
||||
```
|
||||
|
||||
### Swift 示例
|
||||
|
||||
```swift
|
||||
struct NutritionAnalysisRequest: Codable {
|
||||
let imageUrl: String
|
||||
}
|
||||
|
||||
struct NutritionAnalysisItem: Codable {
|
||||
let key: String
|
||||
let name: String
|
||||
let value: String
|
||||
let analysis: String
|
||||
}
|
||||
|
||||
struct NutritionAnalysisResponse: Codable {
|
||||
let success: Bool
|
||||
let data: [NutritionAnalysisItem]
|
||||
let message: String?
|
||||
}
|
||||
|
||||
class NutritionAnalysisService {
|
||||
func analyzeNutritionImage(imageUrl: String, token: String) async throws -> NutritionAnalysisResponse {
|
||||
guard let url = URL(string: "/diet-records/analyze-nutrition-image") else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let requestBody = NutritionAnalysisRequest(imageUrl: imageUrl)
|
||||
request.httpBody = try JSONEncoder().encode(requestBody)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
throw NSError(domain: "APIError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error"])
|
||||
}
|
||||
|
||||
let result = try JSONDecoder().decode(NutritionAnalysisResponse.self, from: data)
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 版本 | 日期 | 更新内容 |
|
||||
|------|------|----------|
|
||||
| 1.0.0 | 2024-10-16 | 初始版本,支持营养成分表图片分析功能 |
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有技术问题或集成困难,请联系开发团队获取支持。
|
||||
474
docs/push-notifications-usage-guide.md
Normal file
474
docs/push-notifications-usage-guide.md
Normal 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推送功能已完全集成到系统中,提供了完整的推送令牌管理、消息发送和模板系统。通过遵循本指南,您可以轻松地在应用中实现各种推送场景,提升用户体验和参与度。
|
||||
|
||||
如有任何问题或需要进一步的技术支持,请参考相关文档或联系开发团队。
|
||||
673
docs/push-service-architecture.md
Normal file
673
docs/push-service-architecture.md
Normal 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 缓存策略
|
||||
- 缓存用户设备令牌减少数据库查询
|
||||
- 缓存推送模板提高渲染性能
|
||||
- 实现分布式缓存支持集群部署
|
||||
169
package-lock.json
generated
169
package-lock.json
generated
@@ -16,8 +16,10 @@
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/sequelize": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.1.0",
|
||||
"@parse/node-apn": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"apns2": "^12.2.0",
|
||||
"axios": "^1.10.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -1886,6 +1888,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
|
||||
@@ -2587,6 +2598,79 @@
|
||||
"npm": ">=5.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@parse/node-apn": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz",
|
||||
"integrity": "sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4.3.3",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"node-forge": "1.3.1",
|
||||
"verror": "1.10.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@parse/node-apn/node_modules/core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@parse/node-apn/node_modules/debug": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz",
|
||||
"integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@parse/node-apn/node_modules/jsonwebtoken": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
|
||||
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parse/node-apn/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/@parse/node-apn/node_modules/verror": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz",
|
||||
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
"extsprintf": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
|
||||
@@ -4331,6 +4415,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/apns2": {
|
||||
"version": "12.2.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz",
|
||||
"integrity": "sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-jwt": "^6.0.1",
|
||||
"undici": "^7.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -4394,6 +4491,17 @@
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-plus": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||
@@ -4747,6 +4855,11 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="
|
||||
},
|
||||
"node_modules/bodec": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz",
|
||||
@@ -6815,6 +6928,21 @@
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-jwt": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz",
|
||||
"integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"asn1.js": "^5.4.1",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"mnemonist": "^0.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
@@ -9536,6 +9664,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -9589,6 +9723,15 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz",
|
||||
@@ -9847,6 +9990,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz",
|
||||
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -9936,6 +10088,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -12901,6 +13059,15 @@
|
||||
"through": "^2.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz",
|
||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
@@ -13536,4 +13703,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/sequelize": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.1.0",
|
||||
"@parse/node-apn": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"apns2": "^12.2.0",
|
||||
"axios": "^1.10.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
72
sql-scripts/push-notifications-tables-create.sql
Normal file
72
sql-scripts/push-notifications-tables-create.sql
Normal 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);
|
||||
@@ -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],
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
|
||||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
|
||||
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
|
||||
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
@@ -27,6 +30,7 @@ export class DietRecordsController {
|
||||
|
||||
constructor(
|
||||
private readonly dietRecordsService: DietRecordsService,
|
||||
private readonly nutritionAnalysisService: NutritionAnalysisService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -161,4 +165,57 @@ export class DietRecordsController {
|
||||
requestDto.mealType
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析食物营养成分表图片
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('analyze-nutrition-image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '分析食物营养成分表图片' })
|
||||
@ApiBody({ type: NutritionAnalysisRequestDto })
|
||||
@ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
@ApiResponse({ status: 500, description: '服务器内部错误' })
|
||||
async analyzeNutritionImage(
|
||||
@Body() requestDto: NutritionAnalysisRequestDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<NutritionAnalysisResponseDto> {
|
||||
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
|
||||
|
||||
if (!requestDto.imageUrl) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '请提供图片URL'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(requestDto.imageUrl);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '图片URL格式不正确'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
|
||||
|
||||
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表分析失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { DietRecordsController } from './diet-records.controller';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
|
||||
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
||||
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
|
||||
forwardRef(() => AiCoachModule),
|
||||
],
|
||||
controllers: [DietRecordsController],
|
||||
providers: [DietRecordsService],
|
||||
exports: [DietRecordsService],
|
||||
providers: [DietRecordsService, NutritionAnalysisService],
|
||||
exports: [DietRecordsService, NutritionAnalysisService],
|
||||
})
|
||||
export class DietRecordsModule { }
|
||||
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal file
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 营养成分分析请求DTO
|
||||
*/
|
||||
export class NutritionAnalysisRequestDto {
|
||||
@ApiProperty({
|
||||
description: '营养成分表图片URL',
|
||||
example: 'https://example.com/nutrition-label.jpg',
|
||||
required: true
|
||||
})
|
||||
imageUrl: string;
|
||||
}
|
||||
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal file
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 营养成分分析结果项
|
||||
*/
|
||||
export class NutritionAnalysisItemDto {
|
||||
@ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' })
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ description: '营养素的中文名称', example: '热量' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' })
|
||||
value: string;
|
||||
|
||||
@ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。' })
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析响应DTO
|
||||
*/
|
||||
export class NutritionAnalysisResponseDto {
|
||||
@ApiProperty({ description: '操作是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
|
||||
data: NutritionAnalysisItemDto[];
|
||||
|
||||
@ApiProperty({ description: '响应消息', required: false })
|
||||
message?: string;
|
||||
}
|
||||
282
src/diet-records/services/nutrition-analysis.service.ts
Normal file
282
src/diet-records/services/nutrition-analysis.service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OpenAI } from 'openai';
|
||||
|
||||
/**
|
||||
* 营养成分分析结果接口
|
||||
*/
|
||||
export interface NutritionAnalysisResult {
|
||||
key: string; // 营养素的唯一标识,如 energy_kcal
|
||||
name: string; // 营养素的中文名称,如"热量"
|
||||
value: string; // 从图片中识别的原始值和单位,如"840千焦"
|
||||
analysis: string; // 针对该营养素的详细健康建议
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析响应接口
|
||||
*/
|
||||
export interface NutritionAnalysisResponse {
|
||||
success: boolean;
|
||||
data: NutritionAnalysisResult[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析服务
|
||||
* 负责处理食物营养成分表的AI分析
|
||||
*
|
||||
* 支持多种AI模型:
|
||||
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
|
||||
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
|
||||
*/
|
||||
@Injectable()
|
||||
export class NutritionAnalysisService {
|
||||
private readonly logger = new Logger(NutritionAnalysisService.name);
|
||||
private readonly client: OpenAI;
|
||||
private readonly visionModel: string;
|
||||
private readonly apiProvider: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// Support both GLM-4.5V and DashScope (Qwen) models
|
||||
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
|
||||
|
||||
if (this.apiProvider === 'glm') {
|
||||
// GLM-4.5V Configuration
|
||||
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
|
||||
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey: glmApiKey,
|
||||
baseURL: glmBaseURL,
|
||||
});
|
||||
|
||||
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
|
||||
} else {
|
||||
// DashScope Configuration (default)
|
||||
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
||||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey: dashScopeApiKey,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析食物营养成分表图片
|
||||
* @param imageUrl 图片URL
|
||||
* @returns 营养成分分析结果
|
||||
*/
|
||||
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
|
||||
try {
|
||||
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
|
||||
|
||||
const prompt = this.buildNutritionAnalysisPrompt();
|
||||
|
||||
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
|
||||
|
||||
const rawResult = completion.choices?.[0]?.message?.content || '[]';
|
||||
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
|
||||
|
||||
return this.parseNutritionAnalysisResult(rawResult);
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表分析失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope
|
||||
* @param prompt 提示文本
|
||||
* @param imageUrls 图片URL数组
|
||||
* @returns API响应
|
||||
*/
|
||||
private async makeVisionApiCall(prompt: string, imageUrls: string[]) {
|
||||
const baseParams = {
|
||||
model: this.visionModel,
|
||||
temperature: 0.3,
|
||||
response_format: { type: 'json_object' } as any,
|
||||
};
|
||||
|
||||
// 处理图片URL
|
||||
const processedImages = imageUrls.map((imageUrl) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: imageUrl } as any,
|
||||
}));
|
||||
|
||||
if (this.apiProvider === 'glm') {
|
||||
// GLM-4.5V format
|
||||
return await this.client.chat.completions.create({
|
||||
...baseParams,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
...processedImages,
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
} else {
|
||||
// DashScope format (default)
|
||||
return await this.client.chat.completions.create({
|
||||
...baseParams,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
...processedImages,
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建营养成分分析提示
|
||||
* @returns 提示文本
|
||||
*/
|
||||
private buildNutritionAnalysisPrompt(): string {
|
||||
return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。
|
||||
|
||||
**任务要求:**
|
||||
1. 识别图片中的营养成分表,提取所有可见的营养素信息
|
||||
2. 为每个营养素提供详细的健康建议和分析
|
||||
3. 返回严格的JSON数组格式,不包含任何额外的解释或对话文本
|
||||
|
||||
**输出格式要求:**
|
||||
请严格按照以下JSON数组格式返回,每个对象包含四个字段:
|
||||
[
|
||||
{
|
||||
"key": "energy_kcal",
|
||||
"name": "热量",
|
||||
"value": "840千焦",
|
||||
"analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。"
|
||||
},
|
||||
{
|
||||
"key": "protein",
|
||||
"name": "蛋白质",
|
||||
"value": "12.5g",
|
||||
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
|
||||
}
|
||||
]
|
||||
|
||||
**营养素标识符对照表:**
|
||||
- 热量/能量: energy_kcal
|
||||
- 蛋白质: protein
|
||||
- 脂肪: fat
|
||||
- 碳水化合物: carbohydrate
|
||||
- 膳食纤维: fiber
|
||||
- 钠: sodium
|
||||
- 钙: calcium
|
||||
- 铁: iron
|
||||
- 锌: zinc
|
||||
- 维生素C: vitamin_c
|
||||
- 维生素A: vitamin_a
|
||||
- 维生素D: vitamin_d
|
||||
- 维生素E: vitamin_e
|
||||
- 维生素B1: vitamin_b1
|
||||
- 维生素B2: vitamin_b2
|
||||
- 维生素B6: vitamin_b6
|
||||
- 维生素B12: vitamin_b12
|
||||
- 叶酸: folic_acid
|
||||
- 胆固醇: cholesterol
|
||||
- 饱和脂肪: saturated_fat
|
||||
- 反式脂肪: trans_fat
|
||||
- 糖: sugar
|
||||
- 其他营养素: other_nutrient
|
||||
|
||||
**分析要求:**
|
||||
1. 如果图片中没有营养成分表,返回空数组 []
|
||||
2. 为每个识别到的营养素提供具体的健康建议
|
||||
3. 建议应包含营养素的作用、摄入量参考和健康影响
|
||||
4. 数值分析要准确,建议要专业且实用
|
||||
5. 只返回JSON数组,不要包含任何其他文本
|
||||
|
||||
**重要提醒:**
|
||||
- 严格按照JSON数组格式返回
|
||||
- 不要添加任何解释性文字或对话内容
|
||||
- 确保JSON格式正确,可以被直接解析`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析营养成分分析结果
|
||||
* @param rawResult 原始结果字符串
|
||||
* @returns 解析后的分析结果
|
||||
*/
|
||||
private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse {
|
||||
try {
|
||||
// 尝试解析JSON
|
||||
let parsedResult: any;
|
||||
try {
|
||||
parsedResult = JSON.parse(rawResult);
|
||||
} catch (parseError) {
|
||||
this.logger.error(`营养成分分析JSON解析失败: ${parseError}`);
|
||||
this.logger.error(`原始结果: ${rawResult}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表解析失败,无法识别有效的营养信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 确保结果是数组
|
||||
if (!Array.isArray(parsedResult)) {
|
||||
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表格式错误,无法识别有效的营养信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证和标准化每个营养素项
|
||||
const nutritionData: NutritionAnalysisResult[] = [];
|
||||
|
||||
for (const item of parsedResult) {
|
||||
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
|
||||
nutritionData.push({
|
||||
key: String(item.key).trim(),
|
||||
name: String(item.name).trim(),
|
||||
value: String(item.value).trim(),
|
||||
analysis: String(item.analysis).trim()
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nutritionData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '图片中未检测到有效的营养成分表信息'
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: nutritionData
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表处理失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
132
src/push-notifications/README_PUSH_TEST.md
Normal file
132
src/push-notifications/README_PUSH_TEST.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 推送测试功能
|
||||
|
||||
本文档介绍如何使用推送测试功能,该功能可以在应用程序启动时自动获取表中的已有 token 进行消息推送。
|
||||
|
||||
## 功能概述
|
||||
|
||||
推送测试功能包括以下特性:
|
||||
|
||||
1. **自动测试**:应用程序启动时自动执行推送测试(可通过环境变量控制)
|
||||
2. **手动触发**:通过 API 接口手动触发推送测试
|
||||
3. **统计信息**:获取推送令牌的统计信息
|
||||
4. **可配置内容**:可以自定义测试推送的标题和内容
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
在 `.env` 文件中添加以下配置:
|
||||
|
||||
```env
|
||||
# 推送测试配置
|
||||
# 启用/禁用应用启动时的推送测试
|
||||
ENABLE_PUSH_TEST=false
|
||||
|
||||
# 测试推送消息内容(可选,如果不提供将使用默认值)
|
||||
PUSH_TEST_TITLE=测试推送
|
||||
PUSH_TEST_BODY=这是一条测试推送消息,用于验证推送功能是否正常工作。
|
||||
```
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
- `ENABLE_PUSH_TEST`: 设置为 `true` 启用应用启动时的推送测试,设置为 `false` 禁用(默认为 `false`)
|
||||
- `PUSH_TEST_TITLE`: 测试推送的标题(可选)
|
||||
- `PUSH_TEST_BODY`: 测试推送的内容(可选)
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 手动触发推送测试
|
||||
|
||||
**请求方式**: `POST`
|
||||
**请求路径**: `/api/push-test/trigger`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "Push test completed",
|
||||
"data": {
|
||||
"success": true,
|
||||
"message": "Push test completed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取推送令牌统计信息
|
||||
|
||||
**请求方式**: `GET`
|
||||
**请求路径**: `/api/push-test/stats`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取推送令牌统计信息成功",
|
||||
"data": {
|
||||
"totalTokens": 100,
|
||||
"activeTokens": 85,
|
||||
"inactiveTokens": 15,
|
||||
"recentlyActiveTokens": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **自动测试流程**:
|
||||
- 应用启动时,`PushTestService` 会检查 `ENABLE_PUSH_TEST` 环境变量
|
||||
- 如果启用,服务会在应用完全启动后 5 秒执行推送测试
|
||||
- 测试会获取最多 10 个活跃的推送令牌
|
||||
- 向这些令牌发送测试推送消息
|
||||
|
||||
2. **手动测试流程**:
|
||||
- 通过调用 `/api/push-test/trigger` 接口手动触发测试
|
||||
- 测试流程与自动测试相同
|
||||
|
||||
3. **统计信息**:
|
||||
- `totalTokens`: 总令牌数
|
||||
- `activeTokens`: 活跃令牌数
|
||||
- `inactiveTokens`: 非活跃令牌数
|
||||
- `recentlyActiveTokens`: 最近 7 天活跃的令牌数
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **生产环境使用**:
|
||||
- 在生产环境中使用前,请确保测试推送内容不会对用户造成困扰
|
||||
- 建议在非高峰时段进行测试
|
||||
|
||||
2. **令牌限制**:
|
||||
- 自动测试每次最多获取 10 个令牌,避免发送过多推送
|
||||
- 只会向标记为 `isActive=true` 的令牌发送推送
|
||||
|
||||
3. **错误处理**:
|
||||
- 如果推送失败,服务会记录详细的错误日志
|
||||
- 无效的令牌会被自动标记为非活跃状态
|
||||
|
||||
## 日志记录
|
||||
|
||||
推送测试功能会记录以下日志:
|
||||
|
||||
- 测试开始和结束
|
||||
- 发送成功和失败的统计
|
||||
- 每个令牌的推送结果
|
||||
- 错误详情
|
||||
|
||||
这些日志可以帮助排查推送问题。
|
||||
|
||||
## 示例用法
|
||||
|
||||
### 启用自动测试
|
||||
|
||||
1. 在 `.env` 文件中设置 `ENABLE_PUSH_TEST=true`
|
||||
2. 重启应用程序
|
||||
3. 查看日志确认测试是否成功执行
|
||||
|
||||
### 手动触发测试
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/api/push-test/trigger
|
||||
```
|
||||
|
||||
### 查看统计信息
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:3002/api/push-test/stats
|
||||
384
src/push-notifications/apns.provider.ts
Normal file
384
src/push-notifications/apns.provider.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2';
|
||||
import * as fs from 'fs';
|
||||
import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface';
|
||||
|
||||
interface SendResult {
|
||||
sent: string[];
|
||||
failed: Array<{
|
||||
device: string;
|
||||
error?: Error;
|
||||
status?: string;
|
||||
response?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ApnsProvider.name);
|
||||
private client: ApnsClient;
|
||||
private config: ApnsConfig;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.config = this.buildConfig();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.initializeClient();
|
||||
this.setupErrorHandlers();
|
||||
this.logger.log('APNs Provider initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize APNs Provider', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await 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 teamId = this.configService.get<string>('APNS_TEAM_ID');
|
||||
const keyId = this.configService.get<string>('APNS_KEY_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');
|
||||
|
||||
if (!teamId || !keyId || !keyPath || !bundleId) {
|
||||
throw new Error('Missing required APNs configuration');
|
||||
}
|
||||
|
||||
let signingKey: string | Buffer;
|
||||
try {
|
||||
// 尝试读取密钥文件
|
||||
if (fs.existsSync(keyPath)) {
|
||||
signingKey = fs.readFileSync(keyPath);
|
||||
} else {
|
||||
// 如果是直接的内容而不是文件路径
|
||||
signingKey = keyPath;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read APNs key file: ${keyPath}`, error);
|
||||
throw new Error(`Invalid APNs key file: ${keyPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
team: teamId,
|
||||
keyId,
|
||||
signingKey,
|
||||
defaultTopic: bundleId,
|
||||
// production: environment === 'production',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化APNs客户端
|
||||
*/
|
||||
private async initializeClient(): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Initializing APNs Client config: ${JSON.stringify(this.config)}`);
|
||||
this.client = new ApnsClient(this.config);
|
||||
this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize APNs Client', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误处理器
|
||||
*/
|
||||
private setupErrorHandlers(): void {
|
||||
// 监听特定错误
|
||||
this.client.on(Errors.badDeviceToken, (err) => {
|
||||
this.logger.error(`Bad device token: ${err}`, err.reason);
|
||||
});
|
||||
|
||||
this.client.on(Errors.unregistered, (err) => {
|
||||
this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason);
|
||||
});
|
||||
|
||||
this.client.on(Errors.topicDisallowed, (err) => {
|
||||
this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason);
|
||||
});
|
||||
|
||||
// 监听所有错误
|
||||
this.client.on(Errors.error, (err) => {
|
||||
this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单个通知
|
||||
*/
|
||||
async send(notification: Notification, deviceTokens: string[]): Promise<SendResult> {
|
||||
const results: SendResult = {
|
||||
sent: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
for (const deviceToken of deviceTokens) {
|
||||
try {
|
||||
// 为每个设备令牌创建新的通知实例
|
||||
const deviceNotification = this.createDeviceNotification(notification, deviceToken);
|
||||
|
||||
this.logger.log(`Sending notification to device this.client.send deviceNotification ${JSON.stringify(deviceNotification, null, 2)}`);
|
||||
|
||||
await this.client.send(deviceNotification);
|
||||
results.sent.push(deviceToken);
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
device: deviceToken,
|
||||
error: error as Error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logResults(results);
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending notification', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送通知
|
||||
*/
|
||||
async sendBatch(notifications: Notification[], deviceTokens: string[]): Promise<SendResult> {
|
||||
const results: SendResult = {
|
||||
sent: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.debug(`Sending ${notifications.length} notifications to ${deviceTokens.length} devices`);
|
||||
|
||||
const deviceNotifications: Notification[] = [];
|
||||
for (const notification of notifications) {
|
||||
for (const deviceToken of deviceTokens) {
|
||||
deviceNotifications.push(this.createDeviceNotification(notification, deviceToken));
|
||||
}
|
||||
}
|
||||
|
||||
const sendResults = await this.client.sendMany(deviceNotifications);
|
||||
|
||||
// 处理 sendMany 的结果
|
||||
sendResults.forEach((result, index) => {
|
||||
const deviceIndex = index % deviceTokens.length;
|
||||
const deviceToken = deviceTokens[deviceIndex];
|
||||
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
results.failed.push({
|
||||
device: deviceToken,
|
||||
error: (result as any).error
|
||||
});
|
||||
} else {
|
||||
results.sent.push(deviceToken);
|
||||
}
|
||||
});
|
||||
|
||||
this.logResults(results);
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending batch notifications', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理推送通道
|
||||
*/
|
||||
async manageChannels(notification: Notification, bundleId: string, action: string): Promise<any> {
|
||||
try {
|
||||
this.logger.debug(`Managing channels for bundle ${bundleId} with action ${action}`);
|
||||
|
||||
// apns2 库没有直接的 manageChannels 方法,这里需要实现自定义逻辑
|
||||
// 或者使用原始的 HTTP 请求来管理通道
|
||||
this.logger.warn(`Channel management not directly supported in apns2 library. Action: ${action}`);
|
||||
|
||||
return { message: 'Channel management not implemented in apns2 library' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error managing channels', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播实时活动通知
|
||||
*/
|
||||
async broadcast(notification: Notification, bundleId: string): Promise<any> {
|
||||
try {
|
||||
this.logger.debug(`Broadcasting to bundle ${bundleId}`);
|
||||
|
||||
// apns2 库没有直接的 broadcast 方法,这里需要实现自定义逻辑
|
||||
// 或者使用原始的 HTTP 请求来广播
|
||||
this.logger.warn(`Broadcast not directly supported in apns2 library. Bundle: ${bundleId}`);
|
||||
|
||||
return { message: 'Broadcast not implemented in apns2 library' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error broadcasting', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准通知
|
||||
*/
|
||||
createNotification(options: ApnsNotificationOptions): Notification {
|
||||
// 构建通知选项
|
||||
const notificationOptions: any = {};
|
||||
|
||||
// 设置 APS 属性
|
||||
const aps: any = {};
|
||||
|
||||
if (options.badge !== undefined) {
|
||||
notificationOptions.badge = options.badge;
|
||||
}
|
||||
|
||||
if (options.sound) {
|
||||
notificationOptions.sound = options.sound;
|
||||
}
|
||||
|
||||
if (options.contentAvailable) {
|
||||
notificationOptions.contentAvailable = true;
|
||||
}
|
||||
|
||||
if (options.mutableContent) {
|
||||
notificationOptions.mutableContent = true;
|
||||
}
|
||||
|
||||
if (options.priority) {
|
||||
notificationOptions.priority = options.priority;
|
||||
}
|
||||
|
||||
if (options.pushType) {
|
||||
notificationOptions.type = options.pushType;
|
||||
}
|
||||
|
||||
// 添加自定义数据
|
||||
if (options.data) {
|
||||
notificationOptions.data = options.data;
|
||||
}
|
||||
|
||||
// 创建通知对象,但不指定设备令牌(将在发送时设置)
|
||||
return new Notification('', notificationOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基本通知
|
||||
*/
|
||||
createBasicNotification(deviceToken: string, title: string, body?: string, options?: Partial<ApnsNotificationOptions>): Notification {
|
||||
// 构建通知选项
|
||||
const notificationOptions: any = {
|
||||
alert: {
|
||||
title,
|
||||
body: body || ''
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.badge !== undefined) {
|
||||
notificationOptions.badge = options.badge;
|
||||
}
|
||||
|
||||
if (options?.sound) {
|
||||
notificationOptions.sound = options.sound;
|
||||
}
|
||||
|
||||
if (options?.contentAvailable) {
|
||||
notificationOptions.contentAvailable = true;
|
||||
}
|
||||
|
||||
if (options?.mutableContent) {
|
||||
notificationOptions.mutableContent = true;
|
||||
}
|
||||
|
||||
if (options?.priority) {
|
||||
notificationOptions.priority = options.priority;
|
||||
}
|
||||
|
||||
if (options?.pushType) {
|
||||
notificationOptions.type = options.pushType;
|
||||
}
|
||||
|
||||
// 添加自定义数据
|
||||
if (options?.data) {
|
||||
notificationOptions.data = options.data;
|
||||
}
|
||||
|
||||
return new Notification(deviceToken, notificationOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建静默通知
|
||||
*/
|
||||
createSilentNotification(deviceToken: string): SilentNotification {
|
||||
return new SilentNotification(deviceToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为特定设备创建通知实例
|
||||
*/
|
||||
private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
|
||||
// 创建新的通知实例,使用相同的选项但不同的设备令牌
|
||||
return new Notification(deviceToken, {
|
||||
alert: notification.options.alert,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录推送结果
|
||||
*/
|
||||
private logResults(results: SendResult): 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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
try {
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
}
|
||||
|
||||
this.logger.log('APNs Provider connections closed');
|
||||
} catch (error) {
|
||||
this.logger.error('Error closing APNs Provider connections', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Provider状态
|
||||
*/
|
||||
getStatus(): { connected: boolean; environment: string } {
|
||||
return {
|
||||
connected: !!this.client,
|
||||
environment: this.config.production ? 'production' : 'sandbox',
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/push-notifications/dto/create-push-template.dto.ts
Normal file
35
src/push-notifications/dto/create-push-template.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
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;
|
||||
}
|
||||
51
src/push-notifications/dto/device-push-response.dto.ts
Normal file
51
src/push-notifications/dto/device-push-response.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ResponseCode } from '../../base.dto';
|
||||
|
||||
export class DevicePushResult {
|
||||
@ApiProperty({ description: '设备令牌' })
|
||||
deviceToken: string;
|
||||
|
||||
@ApiProperty({ description: '用户ID(可选,如果可获取)' })
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '是否成功' })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '错误信息', required: false })
|
||||
error?: string;
|
||||
|
||||
@ApiProperty({ description: 'APNs响应', required: false })
|
||||
apnsResponse?: any;
|
||||
}
|
||||
|
||||
export class DevicePushResponseDto {
|
||||
@ApiProperty({ description: '响应代码' })
|
||||
code: ResponseCode;
|
||||
|
||||
@ApiProperty({ description: '响应消息' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '推送结果' })
|
||||
data: {
|
||||
success: boolean;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
results: DevicePushResult[];
|
||||
};
|
||||
}
|
||||
|
||||
export class BatchDevicePushResponseDto {
|
||||
@ApiProperty({ description: '响应代码' })
|
||||
code: ResponseCode;
|
||||
|
||||
@ApiProperty({ description: '响应消息' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '批量推送结果' })
|
||||
data: {
|
||||
totalTokens: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
results: DevicePushResult[];
|
||||
};
|
||||
}
|
||||
93
src/push-notifications/dto/push-response.dto.ts
Normal file
93
src/push-notifications/dto/push-response.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
29
src/push-notifications/dto/register-device-token.dto.ts
Normal file
29
src/push-notifications/dto/register-device-token.dto.ts
Normal 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;
|
||||
}
|
||||
38
src/push-notifications/dto/send-push-by-template.dto.ts
Normal file
38
src/push-notifications/dto/send-push-by-template.dto.ts
Normal 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;
|
||||
}
|
||||
63
src/push-notifications/dto/send-push-notification.dto.ts
Normal file
63
src/push-notifications/dto/send-push-notification.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
export class 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;
|
||||
}
|
||||
63
src/push-notifications/dto/send-push-to-devices.dto.ts
Normal file
63
src/push-notifications/dto/send-push-to-devices.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
export class SendPushToDevicesDto {
|
||||
@ApiProperty({ description: '设备令牌列表' })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
deviceTokens: string[];
|
||||
|
||||
@ApiProperty({ description: '推送标题' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '推送内容' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
body: string;
|
||||
|
||||
@ApiProperty({ description: '自定义数据', required: false })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
payload?: any;
|
||||
|
||||
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
|
||||
@IsEnum(PushType)
|
||||
@IsOptional()
|
||||
pushType?: PushType;
|
||||
|
||||
@ApiProperty({ description: '优先级', required: false })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
priority?: number;
|
||||
|
||||
@ApiProperty({ description: '过期时间(秒)', required: false })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
expiry?: number;
|
||||
|
||||
@ApiProperty({ description: '折叠ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
collapseId?: string;
|
||||
|
||||
@ApiProperty({ description: '声音', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sound?: string;
|
||||
|
||||
@ApiProperty({ description: '徽章数', required: false })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
badge?: number;
|
||||
|
||||
@ApiProperty({ description: '是否可变内容', required: false })
|
||||
@IsOptional()
|
||||
mutableContent?: boolean;
|
||||
|
||||
@ApiProperty({ description: '是否静默推送', required: false })
|
||||
@IsOptional()
|
||||
contentAvailable?: boolean;
|
||||
}
|
||||
29
src/push-notifications/dto/update-device-token.dto.ts
Normal file
29
src/push-notifications/dto/update-device-token.dto.ts
Normal 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;
|
||||
}
|
||||
35
src/push-notifications/dto/update-push-template.dto.ts
Normal file
35
src/push-notifications/dto/update-push-template.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PushType } from 'apns2';
|
||||
import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator';
|
||||
|
||||
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;
|
||||
}
|
||||
4
src/push-notifications/enums/device-type.enum.ts
Normal file
4
src/push-notifications/enums/device-type.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum DeviceType {
|
||||
IOS = 'IOS',
|
||||
ANDROID = 'ANDROID',
|
||||
}
|
||||
6
src/push-notifications/enums/push-message-status.enum.ts
Normal file
6
src/push-notifications/enums/push-message-status.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum PushMessageStatus {
|
||||
PENDING = 'PENDING',
|
||||
SENT = 'SENT',
|
||||
FAILED = 'FAILED',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
26
src/push-notifications/interfaces/apns-config.interface.ts
Normal file
26
src/push-notifications/interfaces/apns-config.interface.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ApnsConfig {
|
||||
team: string;
|
||||
keyId: string;
|
||||
signingKey: string | Buffer;
|
||||
defaultTopic: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
production?: boolean;
|
||||
}
|
||||
|
||||
export interface ApnsNotificationOptions {
|
||||
topic?: string;
|
||||
id?: string;
|
||||
collapseId?: string;
|
||||
priority?: number;
|
||||
pushType?: string;
|
||||
expiry?: number;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
contentAvailable?: boolean;
|
||||
mutableContent?: boolean;
|
||||
data?: Record<string, any>;
|
||||
title?: string;
|
||||
body?: string;
|
||||
alert?: any;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
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;
|
||||
}
|
||||
147
src/push-notifications/models/push-message.model.ts
Normal file
147
src/push-notifications/models/push-message.model.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript';
|
||||
import { PushMessageStatus } from '../enums/push-message-status.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@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;
|
||||
}
|
||||
95
src/push-notifications/models/push-template.model.ts
Normal file
95
src/push-notifications/models/push-template.model.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@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;
|
||||
}
|
||||
100
src/push-notifications/models/user-push-token.model.ts
Normal file
100
src/push-notifications/models/user-push-token.model.ts
Normal 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: true,
|
||||
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;
|
||||
}
|
||||
387
src/push-notifications/push-message.service.ts
Normal file
387
src/push-notifications/push-message.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/push-notifications/push-notifications.controller.ts
Normal file
115
src/push-notifications/push-notifications.controller.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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 { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
|
||||
import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto';
|
||||
import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-push-response.dto';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('推送通知')
|
||||
@Controller('push-notifications')
|
||||
|
||||
export class PushNotificationsController {
|
||||
constructor(private readonly pushNotificationsService: PushNotificationsService) { }
|
||||
|
||||
@Post('register-token')
|
||||
@ApiOperation({ summary: '注册设备推送令牌' })
|
||||
@Public()
|
||||
@ApiResponse({ status: 200, description: '注册成功', type: RegisterTokenResponseDto })
|
||||
async registerToken(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Body() registerTokenDto: RegisterDeviceTokenDto,
|
||||
): Promise<RegisterTokenResponseDto> {
|
||||
return this.pushNotificationsService.registerToken(registerTokenDto, user?.sub || '');
|
||||
}
|
||||
|
||||
@Put('update-token')
|
||||
@Public()
|
||||
|
||||
@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')
|
||||
@Public()
|
||||
@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: '发送推送通知' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
|
||||
async sendNotification(
|
||||
@Body() sendNotificationDto: SendPushNotificationDto,
|
||||
): Promise<PushResponseDto> {
|
||||
return this.pushNotificationsService.sendNotification(sendNotificationDto);
|
||||
}
|
||||
|
||||
@Post('send-by-template')
|
||||
@ApiOperation({ summary: '使用模板发送推送' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
|
||||
async sendNotificationByTemplate(
|
||||
@Body() sendByTemplateDto: SendPushByTemplateDto,
|
||||
): Promise<PushResponseDto> {
|
||||
return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto);
|
||||
}
|
||||
|
||||
@Post('send-batch')
|
||||
@ApiOperation({ summary: '批量发送推送' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
|
||||
async sendBatchNotifications(
|
||||
@Body() sendBatchDto: SendPushNotificationDto,
|
||||
): Promise<BatchPushResponseDto> {
|
||||
return this.pushNotificationsService.sendBatchNotifications(sendBatchDto);
|
||||
}
|
||||
|
||||
@Post('send-silent')
|
||||
@ApiOperation({ summary: '发送静默推送' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
|
||||
async sendSilentNotification(
|
||||
@Body() body: { userId: string; payload: any },
|
||||
): Promise<PushResponseDto> {
|
||||
return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload);
|
||||
}
|
||||
|
||||
@Post('send-to-devices')
|
||||
@ApiOperation({ summary: '向指定设备发送推送通知' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto })
|
||||
async sendNotificationToDevices(
|
||||
@Body() sendToDevicesDto: SendPushToDevicesDto,
|
||||
): Promise<DevicePushResponseDto> {
|
||||
return this.pushNotificationsService.sendNotificationToDevices(sendToDevicesDto);
|
||||
}
|
||||
|
||||
@Post('send-batch-to-devices')
|
||||
@ApiOperation({ summary: '批量向指定设备发送推送通知' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto })
|
||||
async sendBatchNotificationToDevices(
|
||||
@Body() sendBatchToDevicesDto: SendPushToDevicesDto,
|
||||
): Promise<BatchDevicePushResponseDto> {
|
||||
return this.pushNotificationsService.sendBatchNotificationToDevices(sendBatchToDevicesDto);
|
||||
}
|
||||
}
|
||||
50
src/push-notifications/push-notifications.module.ts
Normal file
50
src/push-notifications/push-notifications.module.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 { PushTestService } from './push-test.service';
|
||||
import { UserPushToken } from './models/user-push-token.model';
|
||||
import { PushMessage } from './models/push-message.model';
|
||||
import { PushTemplate } from './models/push-template.model';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
DatabaseModule,
|
||||
UsersModule,
|
||||
SequelizeModule.forFeature([
|
||||
UserPushToken,
|
||||
PushMessage,
|
||||
PushTemplate,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
PushNotificationsController,
|
||||
PushTemplateController,
|
||||
],
|
||||
providers: [
|
||||
ApnsProvider,
|
||||
PushNotificationsService,
|
||||
PushTokenService,
|
||||
PushTemplateService,
|
||||
PushMessageService,
|
||||
PushTestService,
|
||||
],
|
||||
exports: [
|
||||
ApnsProvider,
|
||||
PushNotificationsService,
|
||||
PushTokenService,
|
||||
PushTemplateService,
|
||||
PushMessageService,
|
||||
PushTestService,
|
||||
],
|
||||
})
|
||||
export class PushNotificationsModule { }
|
||||
790
src/push-notifications/push-notifications.service.ts
Normal file
790
src/push-notifications/push-notifications.service.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
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 { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
|
||||
import { PushResult, BatchPushResult } from './interfaces/push-notification.interface';
|
||||
import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto';
|
||||
import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto';
|
||||
import { ResponseCode } from '../base.dto';
|
||||
import { PushMessageStatus } from './enums/push-message-status.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@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,
|
||||
data: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
priority: notificationData.priority,
|
||||
expiry: notificationData.expiry,
|
||||
collapseId: notificationData.collapseId,
|
||||
topic: this.bundleId,
|
||||
sound: notificationData.sound,
|
||||
badge: notificationData.badge,
|
||||
mutableContent: notificationData.mutableContent,
|
||||
contentAvailable: notificationData.contentAvailable,
|
||||
});
|
||||
|
||||
// 发送推送
|
||||
const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]);
|
||||
|
||||
// 处理结果
|
||||
if (apnsResults.sent.length > 0) {
|
||||
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults);
|
||||
await this.pushTokenService.updateLastUsedTime(deviceToken);
|
||||
results.push({
|
||||
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,
|
||||
data: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
priority: notificationData.priority,
|
||||
expiry: notificationData.expiry,
|
||||
collapseId: notificationData.collapseId,
|
||||
topic: this.bundleId,
|
||||
sound: notificationData.sound,
|
||||
badge: notificationData.badge,
|
||||
mutableContent: notificationData.mutableContent,
|
||||
contentAvailable: notificationData.contentAvailable,
|
||||
});
|
||||
|
||||
// 批量发送推送
|
||||
const 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.includes(deviceToken) ?
|
||||
{ device: deviceToken, success: true } :
|
||||
apnsResults.failed.find(f => f.device === deviceToken);
|
||||
|
||||
if (apnsResult) {
|
||||
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
|
||||
// 成功发送
|
||||
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
|
||||
await this.pushTokenService.updateLastUsedTime(deviceToken);
|
||||
results.push({
|
||||
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(tokenData: any, userId?: string,): Promise<any> {
|
||||
try {
|
||||
const token = await this.pushTokenService.registerToken(tokenData, userId);
|
||||
this.logger.log(`Registered device token for user ${userId}: ${token.id}`);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '设备令牌注册成功',
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于设备令牌发送推送通知
|
||||
*/
|
||||
async sendNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<DevicePushResponseDto> {
|
||||
try {
|
||||
this.logger.log(`Sending push notification to ${notificationData.deviceTokens.length} devices`);
|
||||
|
||||
const results: DevicePushResult[] = [];
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 为每个设备令牌创建消息记录并发送推送
|
||||
for (const deviceToken of notificationData.deviceTokens) {
|
||||
try {
|
||||
// 尝试获取设备令牌对应的用户ID
|
||||
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
|
||||
|
||||
// 创建消息记录
|
||||
const messageData: CreatePushMessageDto = {
|
||||
userId: userId || '',
|
||||
deviceToken,
|
||||
messageType: 'manual',
|
||||
title: notificationData.title,
|
||||
body: notificationData.body,
|
||||
payload: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
priority: notificationData.priority,
|
||||
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
|
||||
collapseId: notificationData.collapseId,
|
||||
};
|
||||
|
||||
const message = await this.pushMessageService.createMessage(messageData);
|
||||
|
||||
// 创建APNs通知
|
||||
const apnsNotification = this.apnsProvider.createNotification({
|
||||
title: notificationData.title,
|
||||
body: notificationData.body,
|
||||
data: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
priority: notificationData.priority,
|
||||
expiry: notificationData.expiry,
|
||||
collapseId: notificationData.collapseId,
|
||||
topic: this.bundleId,
|
||||
sound: notificationData.sound,
|
||||
badge: notificationData.badge,
|
||||
mutableContent: notificationData.mutableContent,
|
||||
contentAvailable: notificationData.contentAvailable,
|
||||
});
|
||||
|
||||
// 发送推送
|
||||
const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]);
|
||||
|
||||
// 处理结果
|
||||
if (apnsResults.sent.length > 0) {
|
||||
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults);
|
||||
await this.pushTokenService.updateLastUsedTime(deviceToken);
|
||||
results.push({
|
||||
deviceToken,
|
||||
userId: userId || undefined,
|
||||
success: true,
|
||||
apnsResponse: apnsResults,
|
||||
});
|
||||
sentCount++;
|
||||
} else {
|
||||
const failure = apnsResults.failed[0];
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
PushMessageStatus.FAILED,
|
||||
failure.response,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
if (userId) {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
} else {
|
||||
// 如果没有用户ID,直接停用令牌
|
||||
await this.pushTokenService.deactivateToken(deviceToken);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
deviceToken,
|
||||
userId: userId || undefined,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
apnsResponse: failure.response,
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send push to device ${deviceToken}: ${error.message}`, error);
|
||||
results.push({
|
||||
deviceToken,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const success = failedCount === 0;
|
||||
|
||||
return {
|
||||
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
|
||||
message: success ? '推送发送成功' : '部分推送发送失败',
|
||||
data: {
|
||||
success,
|
||||
sentCount,
|
||||
failedCount,
|
||||
results,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send push notification to devices: ${error.message}`, error);
|
||||
return {
|
||||
code: ResponseCode.ERROR,
|
||||
message: `推送发送失败: ${error.message}`,
|
||||
data: {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
failedCount: notificationData.deviceTokens.length,
|
||||
results: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量基于设备令牌发送推送通知
|
||||
*/
|
||||
async sendBatchNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<BatchDevicePushResponseDto> {
|
||||
try {
|
||||
this.logger.log(`Sending batch push notification to ${notificationData.deviceTokens.length} devices`);
|
||||
|
||||
const results: DevicePushResult[] = [];
|
||||
let totalTokens = notificationData.deviceTokens.length;
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 创建APNs通知
|
||||
const apnsNotification = this.apnsProvider.createNotification({
|
||||
alert: notificationData.title,
|
||||
title: notificationData.title,
|
||||
body: notificationData.body,
|
||||
data: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
topic: this.bundleId,
|
||||
sound: notificationData.sound,
|
||||
badge: notificationData.badge,
|
||||
});
|
||||
|
||||
this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`);
|
||||
|
||||
// 批量发送推送
|
||||
const apnsResults = await this.apnsProvider.send(apnsNotification, notificationData.deviceTokens);
|
||||
|
||||
// 处理结果并创建消息记录
|
||||
for (const deviceToken of notificationData.deviceTokens) {
|
||||
try {
|
||||
// 尝试获取设备令牌对应的用户ID
|
||||
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
|
||||
|
||||
// 创建消息记录
|
||||
const messageData: CreatePushMessageDto = {
|
||||
userId: userId || '',
|
||||
deviceToken,
|
||||
messageType: 'batch',
|
||||
title: notificationData.title,
|
||||
body: notificationData.body,
|
||||
payload: notificationData.payload,
|
||||
pushType: notificationData.pushType,
|
||||
priority: notificationData.priority,
|
||||
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
|
||||
collapseId: notificationData.collapseId,
|
||||
};
|
||||
|
||||
const message = await this.pushMessageService.createMessage(messageData);
|
||||
|
||||
// 查找对应的APNs结果
|
||||
const apnsResult = apnsResults.sent.includes(deviceToken) ?
|
||||
{ device: deviceToken, success: true } :
|
||||
apnsResults.failed.find(f => f.device === deviceToken);
|
||||
|
||||
if (apnsResult) {
|
||||
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
|
||||
// 成功发送
|
||||
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
|
||||
await this.pushTokenService.updateLastUsedTime(deviceToken);
|
||||
results.push({
|
||||
deviceToken,
|
||||
userId: userId || undefined,
|
||||
success: true,
|
||||
apnsResponse: apnsResult,
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
// 发送失败
|
||||
const failure = apnsResult as any;
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
PushMessageStatus.FAILED,
|
||||
failure.response,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
if (userId) {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
} else {
|
||||
// 如果没有用户ID,直接停用令牌
|
||||
await this.pushTokenService.deactivateToken(deviceToken);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
deviceToken,
|
||||
userId: userId || undefined,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
apnsResponse: failure.response,
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
} else {
|
||||
// 未找到结果,标记为失败
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
PushMessageStatus.FAILED,
|
||||
null,
|
||||
'No APNs result found'
|
||||
);
|
||||
results.push({
|
||||
deviceToken,
|
||||
userId: userId || undefined,
|
||||
success: false,
|
||||
error: 'No APNs result found',
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to process batch push result for device ${deviceToken}: ${error.message}`, error);
|
||||
results.push({
|
||||
deviceToken,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const success = failedCount === 0;
|
||||
|
||||
return {
|
||||
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
|
||||
message: success ? '批量推送发送成功' : '部分批量推送发送失败',
|
||||
data: {
|
||||
totalTokens,
|
||||
successCount,
|
||||
failedCount,
|
||||
results,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send batch push notification to devices: ${error.message}`, error);
|
||||
return {
|
||||
code: ResponseCode.ERROR,
|
||||
message: `批量推送发送失败: ${error.message}`,
|
||||
data: {
|
||||
totalTokens: notificationData.deviceTokens.length,
|
||||
successCount: 0,
|
||||
failedCount: notificationData.deviceTokens.length,
|
||||
results: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/push-notifications/push-template.controller.ts
Normal file
104
src/push-notifications/push-template.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
280
src/push-notifications/push-template.service.ts
Normal file
280
src/push-notifications/push-template.service.ts
Normal 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)]; // 去重
|
||||
}
|
||||
}
|
||||
99
src/push-notifications/push-test.service.ts
Normal file
99
src/push-notifications/push-test.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PushNotificationsService } from './push-notifications.service';
|
||||
import { PushTokenService } from './push-token.service';
|
||||
import { UserPushToken } from './models/user-push-token.model';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { Op } from 'sequelize';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@Injectable()
|
||||
export class PushTestService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PushTestService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(UserPushToken)
|
||||
private readonly pushTokenModel: typeof UserPushToken,
|
||||
private readonly pushNotificationsService: PushNotificationsService,
|
||||
private readonly pushTokenService: PushTokenService,
|
||||
private readonly configService: ConfigService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 模块初始化时执行
|
||||
*/
|
||||
async onModuleInit() {
|
||||
// 检查是否启用推送测试
|
||||
const enablePushTest = this.configService.get<boolean>('ENABLE_PUSH_TEST', false);
|
||||
|
||||
if (!enablePushTest) {
|
||||
this.logger.log('Push test is disabled. Skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟执行,确保应用完全启动
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.performPushTest();
|
||||
} catch (error) {
|
||||
this.logger.error(`Push test failed: ${error.message}`, error);
|
||||
}
|
||||
}, 5000); // 5秒后执行
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行推送测试
|
||||
*/
|
||||
private async performPushTest(): Promise<void> {
|
||||
this.logger.log('Starting push test...');
|
||||
|
||||
try {
|
||||
// 获取所有活跃的推送令牌
|
||||
const activeTokens = await this.pushTokenModel.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
limit: 10, // 限制测试数量,避免发送过多推送
|
||||
});
|
||||
|
||||
if (activeTokens.length === 0) {
|
||||
this.logger.log('No active push tokens found for testing');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${activeTokens.length} active tokens for testing`);
|
||||
|
||||
// 准备测试推送内容
|
||||
const testTitle = this.configService.get<string>('PUSH_TEST_TITLE', '测试推送');
|
||||
const testBody = this.configService.get<string>('PUSH_TEST_BODY', '这是一条测试推送消息,用于验证推送功能是否正常工作。');
|
||||
|
||||
// 发送测试推送
|
||||
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
|
||||
deviceTokens: activeTokens.map(token => token.deviceToken),
|
||||
title: testTitle,
|
||||
body: testBody,
|
||||
pushType: PushType.alert,
|
||||
});
|
||||
|
||||
if (result.code === 0) {
|
||||
this.logger.log(`Push test completed successfully. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
|
||||
} else {
|
||||
this.logger.warn(`Push test completed with issues. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
|
||||
}
|
||||
|
||||
// 记录详细结果
|
||||
if (result.data.results && result.data.results.length > 0) {
|
||||
result.data.results.forEach((resultItem, index) => {
|
||||
if (resultItem.success) {
|
||||
this.logger.log(`Push test success for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...`);
|
||||
} else {
|
||||
this.logger.warn(`Push test failed for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...: ${resultItem.error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error during push test: ${error.message}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
354
src/push-notifications/push-token.service.ts
Normal file
354
src/push-notifications/push-token.service.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> {
|
||||
try {
|
||||
this.logger.log(`Registering push token for device ${tokenData.deviceToken}`);
|
||||
|
||||
// 检查是否已存在相同的令牌
|
||||
const existingToken = await this.pushTokenModel.findOne({
|
||||
where: {
|
||||
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 device ${tokenData.deviceToken}`);
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
|
||||
// 创建新令牌
|
||||
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 device ${tokenData.deviceToken}`);
|
||||
return newToken;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to register push token for device ${tokenData.deviceToken}: ${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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接停用设备令牌(不需要用户ID)
|
||||
*/
|
||||
async deactivateToken(deviceToken: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Deactivating push token: ${deviceToken}`);
|
||||
|
||||
const token = await this.pushTokenModel.findOne({
|
||||
where: {
|
||||
deviceToken,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn(`Device token not found or already inactive: ${deviceToken}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await token.update({
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully deactivated push token: ${deviceToken}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deactivate push token: ${deviceToken}: ${error.message}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
yarn.lock
107
yarn.lock
@@ -872,6 +872,11 @@
|
||||
resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz"
|
||||
integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==
|
||||
|
||||
"@lukeed/ms@^2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz"
|
||||
integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==
|
||||
|
||||
"@microsoft/tsdoc@0.15.1":
|
||||
version "0.15.1"
|
||||
resolved "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz"
|
||||
@@ -1121,6 +1126,16 @@
|
||||
dependencies:
|
||||
consola "^3.2.3"
|
||||
|
||||
"@parse/node-apn@^5.0.0":
|
||||
version "5.2.3"
|
||||
resolved "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz"
|
||||
integrity sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ==
|
||||
dependencies:
|
||||
debug "4.3.3"
|
||||
jsonwebtoken "9.0.0"
|
||||
node-forge "1.3.1"
|
||||
verror "1.10.1"
|
||||
|
||||
"@pkgr/core@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz"
|
||||
@@ -2156,6 +2171,14 @@ anymatch@^3.0.3, anymatch@~3.1.2:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
apns2@^12.2.0:
|
||||
version "12.2.0"
|
||||
resolved "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz"
|
||||
integrity sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA==
|
||||
dependencies:
|
||||
fast-jwt "^6.0.1"
|
||||
undici "^7.9.0"
|
||||
|
||||
append-field@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz"
|
||||
@@ -2193,6 +2216,16 @@ asap@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz"
|
||||
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==
|
||||
|
||||
asn1.js@^5.4.1:
|
||||
version "5.4.1"
|
||||
resolved "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz"
|
||||
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
|
||||
dependencies:
|
||||
bn.js "^4.0.0"
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
asn1@~0.2.3:
|
||||
version "0.2.6"
|
||||
resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz"
|
||||
@@ -2389,6 +2422,11 @@ blessed@0.1.81:
|
||||
resolved "https://mirrors.tencent.com/npm/blessed/-/blessed-0.1.81.tgz"
|
||||
integrity sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==
|
||||
|
||||
bn.js@^4.0.0:
|
||||
version "4.12.2"
|
||||
resolved "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz"
|
||||
integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==
|
||||
|
||||
bodec@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz"
|
||||
@@ -2954,7 +2992,7 @@ data-uri-to-buffer@^6.0.2:
|
||||
|
||||
dayjs@^1.11.18, dayjs@~1.11.13:
|
||||
version "1.11.18"
|
||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz"
|
||||
resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz"
|
||||
integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==
|
||||
|
||||
dayjs@~1.8.24:
|
||||
@@ -2976,6 +3014,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
debug@4.3.3:
|
||||
version "4.3.3"
|
||||
resolved "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz"
|
||||
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
debug@4.3.6:
|
||||
version "4.3.6"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz"
|
||||
@@ -3134,7 +3179,7 @@ ecc-jsbn@~0.1.1:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://mirrors.tencent.com/npm/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
@@ -3567,6 +3612,16 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta
|
||||
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
fast-jwt@^6.0.1:
|
||||
version "6.0.2"
|
||||
resolved "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz"
|
||||
integrity sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==
|
||||
dependencies:
|
||||
"@lukeed/ms" "^2.0.2"
|
||||
asn1.js "^5.4.1"
|
||||
ecdsa-sig-formatter "^1.0.11"
|
||||
mnemonist "^0.40.0"
|
||||
|
||||
fast-levenshtein@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
|
||||
@@ -4212,7 +4267,7 @@ inflight@^1.0.4:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@@ -4904,6 +4959,16 @@ jsonfile@^6.0.1:
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsonwebtoken@9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz"
|
||||
integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==
|
||||
dependencies:
|
||||
jws "^3.2.2"
|
||||
lodash "^4.17.21"
|
||||
ms "^2.1.1"
|
||||
semver "^7.3.8"
|
||||
|
||||
jsonwebtoken@9.0.2, jsonwebtoken@^9.0.2:
|
||||
version "9.0.2"
|
||||
resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz"
|
||||
@@ -5290,6 +5355,11 @@ mimic-response@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz"
|
||||
integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==
|
||||
|
||||
minimalistic-assert@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz"
|
||||
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
|
||||
|
||||
minimatch@^10.0.0:
|
||||
version "10.0.1"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz"
|
||||
@@ -5340,6 +5410,13 @@ mkdirp@^0.5.4:
|
||||
dependencies:
|
||||
minimist "^1.2.6"
|
||||
|
||||
mnemonist@^0.40.0:
|
||||
version "0.40.3"
|
||||
resolved "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz"
|
||||
integrity sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==
|
||||
dependencies:
|
||||
obliterator "^2.0.4"
|
||||
|
||||
module-details-from-path@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz"
|
||||
@@ -5472,6 +5549,11 @@ node-fetch@^2.6.7:
|
||||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
node-forge@1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz"
|
||||
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz"
|
||||
@@ -5519,6 +5601,11 @@ object-inspect@^1.13.3:
|
||||
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz"
|
||||
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
|
||||
|
||||
obliterator@^2.0.4:
|
||||
version "2.0.5"
|
||||
resolved "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz"
|
||||
integrity sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==
|
||||
|
||||
on-finished@2.4.1, on-finished@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
|
||||
@@ -7108,6 +7195,11 @@ undici-types@~6.20.0:
|
||||
resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz"
|
||||
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
|
||||
|
||||
undici@^7.9.0:
|
||||
version "7.16.0"
|
||||
resolved "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz"
|
||||
integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==
|
||||
|
||||
universalify@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz"
|
||||
@@ -7191,6 +7283,15 @@ verror@1.10.0:
|
||||
core-util-is "1.0.2"
|
||||
extsprintf "^1.2.0"
|
||||
|
||||
verror@1.10.1:
|
||||
version "1.10.1"
|
||||
resolved "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz"
|
||||
integrity sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
core-util-is "1.0.2"
|
||||
extsprintf "^1.2.0"
|
||||
|
||||
vizion@~2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://mirrors.tencent.com/npm/vizion/-/vizion-2.2.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user