feat(push): 新增设备推送和测试功能
- 新增基于设备令牌的推送通知接口 - 添加推送测试服务,支持应用启动时自动测试 - 新增推送测试文档说明 - 更新 APNS 配置和日志记录 - 迁移至 apns2 库的 PushType 枚举 - 替换订阅密钥文件 - 添加项目规则文档
This commit is contained in:
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
|
||||
@@ -77,9 +77,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
keyId,
|
||||
signingKey,
|
||||
defaultTopic: bundleId,
|
||||
host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com',
|
||||
port: 443,
|
||||
production: environment === 'production',
|
||||
// production: environment === 'production',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +86,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
private async initializeClient(): Promise<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) {
|
||||
@@ -102,7 +101,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
private setupErrorHandlers(): void {
|
||||
// 监听特定错误
|
||||
this.client.on(Errors.badDeviceToken, (err) => {
|
||||
this.logger.error(`Bad device token: ${err.deviceToken}`, err.reason);
|
||||
this.logger.error(`Bad device token: ${err}`, err.reason);
|
||||
});
|
||||
|
||||
this.client.on(Errors.unregistered, (err) => {
|
||||
@@ -129,12 +128,14 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.debug(`Sending notification to ${deviceTokens.length} devices`);
|
||||
|
||||
for (const deviceToken of deviceTokens) {
|
||||
try {
|
||||
// 为每个设备令牌创建新的通知实例
|
||||
const deviceNotification = this.createDeviceNotification(notification, deviceToken);
|
||||
|
||||
this.logger.log(`Sending notification to device this.client.send deviceNotification ${JSON.stringify(deviceNotification, null, 2)}`);
|
||||
|
||||
await this.client.send(deviceNotification);
|
||||
results.sent.push(deviceToken);
|
||||
} catch (error) {
|
||||
@@ -332,7 +333,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
|
||||
// 创建新的通知实例,使用相同的选项但不同的设备令牌
|
||||
return new Notification(deviceToken, notification.options);
|
||||
return new Notification(deviceToken, {
|
||||
alert: notification.options.alert,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
export class CreatePushTemplateDto {
|
||||
@ApiProperty({ description: '模板键' })
|
||||
|
||||
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[];
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
export class SendPushNotificationDto {
|
||||
@ApiProperty({ description: '用户ID列表' })
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PushType } from 'apns2';
|
||||
import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator';
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
|
||||
export class UpdatePushTemplateDto {
|
||||
@ApiProperty({ description: '模板标题', required: false })
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum PushType {
|
||||
ALERT = 'ALERT',
|
||||
BACKGROUND = 'BACKGROUND',
|
||||
VOIP = 'VOIP',
|
||||
LIVEACTIVITY = 'LIVEACTIVITY',
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
export interface PushNotificationRequest {
|
||||
userIds: string[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript';
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
import { PushMessageStatus } from '../enums/push-message-status.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@Table({
|
||||
tableName: 't_push_messages',
|
||||
@@ -77,7 +77,7 @@ export class PushMessage extends Model {
|
||||
@Column({
|
||||
type: DataType.ENUM(...Object.values(PushType)),
|
||||
allowNull: false,
|
||||
defaultValue: PushType.ALERT,
|
||||
defaultValue: PushType.alert,
|
||||
comment: '推送类型',
|
||||
})
|
||||
declare pushType: PushType;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript';
|
||||
import { PushType } from '../enums/push-type.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@Table({
|
||||
tableName: 't_push_templates',
|
||||
@@ -58,7 +58,7 @@ export class PushTemplate extends Model {
|
||||
@Column({
|
||||
type: DataType.ENUM(...Object.values(PushType)),
|
||||
allowNull: false,
|
||||
defaultValue: PushType.ALERT,
|
||||
defaultValue: PushType.alert,
|
||||
field: 'push_type',
|
||||
comment: '推送类型',
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ export class UserPushToken extends Model {
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { RegisterDeviceTokenDto } from './dto/register-device-token.dto';
|
||||
import { UpdateDeviceTokenDto } from './dto/update-device-token.dto';
|
||||
import { SendPushNotificationDto } from './dto/send-push-notification.dto';
|
||||
import { SendPushByTemplateDto } from './dto/send-push-by-template.dto';
|
||||
import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
|
||||
import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto';
|
||||
import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-push-response.dto';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
@@ -25,7 +27,7 @@ export class PushNotificationsController {
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Body() registerTokenDto: RegisterDeviceTokenDto,
|
||||
): Promise<RegisterTokenResponseDto> {
|
||||
return this.pushNotificationsService.registerToken(registerTokenDto, user.sub);
|
||||
return this.pushNotificationsService.registerToken(registerTokenDto, user?.sub || '');
|
||||
}
|
||||
|
||||
@Put('update-token')
|
||||
@@ -37,7 +39,7 @@ export class PushNotificationsController {
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Body() updateTokenDto: UpdateDeviceTokenDto,
|
||||
): Promise<UpdateTokenResponseDto> {
|
||||
return this.pushNotificationsService.updateToken(user.sub, updateTokenDto);
|
||||
return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto);
|
||||
}
|
||||
|
||||
@Delete('unregister-token')
|
||||
@@ -48,7 +50,7 @@ export class PushNotificationsController {
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Body() body: { deviceToken: string },
|
||||
): Promise<UnregisterTokenResponseDto> {
|
||||
return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken);
|
||||
return this.pushNotificationsService.unregisterToken(user?.sub || '', body.deviceToken);
|
||||
}
|
||||
|
||||
@Post('send')
|
||||
@@ -90,4 +92,24 @@ export class PushNotificationsController {
|
||||
): Promise<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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ApnsProvider } from './apns.provider';
|
||||
import { PushTokenService } from './push-token.service';
|
||||
import { PushTemplateService } from './push-template.service';
|
||||
import { PushMessageService } from './push-message.service';
|
||||
import { PushTestService } from './push-test.service';
|
||||
import { UserPushToken } from './models/user-push-token.model';
|
||||
import { PushMessage } from './models/push-message.model';
|
||||
import { PushTemplate } from './models/push-template.model';
|
||||
@@ -35,6 +36,7 @@ import { UsersModule } from '../users/users.module';
|
||||
PushTokenService,
|
||||
PushTemplateService,
|
||||
PushMessageService,
|
||||
PushTestService,
|
||||
],
|
||||
exports: [
|
||||
ApnsProvider,
|
||||
@@ -42,6 +44,7 @@ import { UsersModule } from '../users/users.module';
|
||||
PushTokenService,
|
||||
PushTemplateService,
|
||||
PushMessageService,
|
||||
PushTestService,
|
||||
],
|
||||
})
|
||||
export class PushNotificationsModule { }
|
||||
@@ -6,11 +6,13 @@ import { PushTemplateService } from './push-template.service';
|
||||
import { PushMessageService, CreatePushMessageDto } from './push-message.service';
|
||||
import { SendPushNotificationDto } from './dto/send-push-notification.dto';
|
||||
import { SendPushByTemplateDto } from './dto/send-push-by-template.dto';
|
||||
import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
|
||||
import { PushResult, BatchPushResult } from './interfaces/push-notification.interface';
|
||||
import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto';
|
||||
import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto';
|
||||
import { ResponseCode } from '../base.dto';
|
||||
import { PushType } from './enums/push-type.enum';
|
||||
import { PushMessageStatus } from './enums/push-message-status.enum';
|
||||
import { PushType } from 'apns2';
|
||||
|
||||
@Injectable()
|
||||
export class PushNotificationsService {
|
||||
@@ -402,7 +404,7 @@ export class PushNotificationsService {
|
||||
title: '',
|
||||
body: '',
|
||||
payload,
|
||||
pushType: PushType.BACKGROUND,
|
||||
pushType: PushType.background,
|
||||
contentAvailable: true,
|
||||
};
|
||||
|
||||
@@ -428,6 +430,7 @@ export class PushNotificationsService {
|
||||
async registerToken(tokenData: any, userId?: string,): Promise<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: '设备令牌注册成功',
|
||||
@@ -500,4 +503,288 @@ export class PushNotificationsService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于设备令牌发送推送通知
|
||||
*/
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,12 +20,11 @@ export class PushTokenService {
|
||||
*/
|
||||
async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> {
|
||||
try {
|
||||
this.logger.log(`Registering push token for user ${userId}`);
|
||||
this.logger.log(`Registering push token for device ${tokenData.deviceToken}`);
|
||||
|
||||
// 检查是否已存在相同的令牌
|
||||
const existingToken = await this.pushTokenModel.findOne({
|
||||
where: {
|
||||
userId,
|
||||
deviceToken: tokenData.deviceToken,
|
||||
},
|
||||
});
|
||||
@@ -41,7 +40,7 @@ export class PushTokenService {
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Updated existing push token for user ${userId}`);
|
||||
this.logger.log(`Updated existing push token for device ${tokenData.deviceToken}`);
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
@@ -58,10 +57,10 @@ export class PushTokenService {
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully registered new push token for user ${userId}`);
|
||||
this.logger.log(`Successfully registered new push token for device ${tokenData.deviceToken}`);
|
||||
return newToken;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to register push token for user ${userId}: ${error.message}`, error);
|
||||
this.logger.error(`Failed to register push token for device ${tokenData.deviceToken}: ${error.message}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -322,4 +321,34 @@ export class PushTokenService {
|
||||
this.logger.error(`Failed to update last used time: ${error.message}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接停用设备令牌(不需要用户ID)
|
||||
*/
|
||||
async deactivateToken(deviceToken: string): Promise<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user