feat(push): 新增设备推送和测试功能

- 新增基于设备令牌的推送通知接口
- 添加推送测试服务,支持应用启动时自动测试
- 新增推送测试文档说明
- 更新 APNS 配置和日志记录
- 迁移至 apns2 库的 PushType 枚举
- 替换订阅密钥文件
- 添加项目规则文档
This commit is contained in:
richarjiang
2025-10-15 19:09:51 +08:00
parent 38dd740c8c
commit cc83b84c80
20 changed files with 728 additions and 37 deletions

8
.kilocode/rules/rule.md Normal file
View File

@@ -0,0 +1,8 @@
# rule.md
这是一个 nodejs 基于 nestjs 框架的项目
## 指导原则
- 不要随意新增 markdown 文档
- 代码提交 message 用中文

View File

@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V
UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH
GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F
Db0U/xrS
-----END PRIVATE KEY-----

View File

@@ -1,6 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW
4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT
FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb
8U5D2WWu
-----END PRIVATE KEY-----

View 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

View File

@@ -77,9 +77,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
keyId, keyId,
signingKey, signingKey,
defaultTopic: bundleId, defaultTopic: bundleId,
host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com', // production: environment === 'production',
port: 443,
production: environment === 'production',
}; };
} }
@@ -88,6 +86,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
*/ */
private async initializeClient(): Promise<void> { private async initializeClient(): Promise<void> {
try { try {
this.logger.log(`Initializing APNs Client config: ${JSON.stringify(this.config)}`);
this.client = new ApnsClient(this.config); this.client = new ApnsClient(this.config);
this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`); this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`);
} catch (error) { } catch (error) {
@@ -102,7 +101,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
private setupErrorHandlers(): void { private setupErrorHandlers(): void {
// 监听特定错误 // 监听特定错误
this.client.on(Errors.badDeviceToken, (err) => { 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) => { this.client.on(Errors.unregistered, (err) => {
@@ -129,12 +128,14 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
}; };
try { try {
this.logger.debug(`Sending notification to ${deviceTokens.length} devices`);
for (const deviceToken of deviceTokens) { for (const deviceToken of deviceTokens) {
try { try {
// 为每个设备令牌创建新的通知实例 // 为每个设备令牌创建新的通知实例
const deviceNotification = this.createDeviceNotification(notification, deviceToken); 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); await this.client.send(deviceNotification);
results.sent.push(deviceToken); results.sent.push(deviceToken);
} catch (error) { } catch (error) {
@@ -332,7 +333,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
*/ */
private createDeviceNotification(notification: Notification, deviceToken: string): Notification { private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
// 创建新的通知实例,使用相同的选项但不同的设备令牌 // 创建新的通知实例,使用相同的选项但不同的设备令牌
return new Notification(deviceToken, notification.options); return new Notification(deviceToken, {
alert: notification.options.alert,
});
} }
/** /**

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { PushType } from '../enums/push-type.enum'; import { PushType } from 'apns2';
export class CreatePushTemplateDto { export class CreatePushTemplateDto {
@ApiProperty({ description: '模板键' }) @ApiProperty({ description: '模板键' })

View 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[];
};
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; 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 { export class SendPushNotificationDto {
@ApiProperty({ description: '用户ID列表' }) @ApiProperty({ description: '用户ID列表' })

View File

@@ -0,0 +1,63 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { PushType } from '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;
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { PushType } from 'apns2';
import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator'; import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator';
import { PushType } from '../enums/push-type.enum';
export class UpdatePushTemplateDto { export class UpdatePushTemplateDto {
@ApiProperty({ description: '模板标题', required: false }) @ApiProperty({ description: '模板标题', required: false })

View File

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

View File

@@ -1,4 +1,4 @@
import { PushType } from '../enums/push-type.enum'; import { PushType } from 'apns2';
export interface PushNotificationRequest { export interface PushNotificationRequest {
userIds: string[]; userIds: string[];

View File

@@ -1,6 +1,6 @@
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript'; 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 { PushMessageStatus } from '../enums/push-message-status.enum';
import { PushType } from 'apns2';
@Table({ @Table({
tableName: 't_push_messages', tableName: 't_push_messages',
@@ -77,7 +77,7 @@ export class PushMessage extends Model {
@Column({ @Column({
type: DataType.ENUM(...Object.values(PushType)), type: DataType.ENUM(...Object.values(PushType)),
allowNull: false, allowNull: false,
defaultValue: PushType.ALERT, defaultValue: PushType.alert,
comment: '推送类型', comment: '推送类型',
}) })
declare pushType: PushType; declare pushType: PushType;

View File

@@ -1,5 +1,5 @@
import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript'; import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript';
import { PushType } from '../enums/push-type.enum'; import { PushType } from 'apns2';
@Table({ @Table({
tableName: 't_push_templates', tableName: 't_push_templates',
@@ -58,7 +58,7 @@ export class PushTemplate extends Model {
@Column({ @Column({
type: DataType.ENUM(...Object.values(PushType)), type: DataType.ENUM(...Object.values(PushType)),
allowNull: false, allowNull: false,
defaultValue: PushType.ALERT, defaultValue: PushType.alert,
field: 'push_type', field: 'push_type',
comment: '推送类型', comment: '推送类型',
}) })

View File

@@ -30,7 +30,7 @@ export class UserPushToken extends Model {
@Column({ @Column({
type: DataType.STRING, type: DataType.STRING,
allowNull: false, allowNull: true,
comment: '用户ID', comment: '用户ID',
}) })
declare userId: string; declare userId: string;

View File

@@ -5,7 +5,9 @@ import { RegisterDeviceTokenDto } from './dto/register-device-token.dto';
import { UpdateDeviceTokenDto } from './dto/update-device-token.dto'; import { UpdateDeviceTokenDto } from './dto/update-device-token.dto';
import { SendPushNotificationDto } from './dto/send-push-notification.dto'; import { SendPushNotificationDto } from './dto/send-push-notification.dto';
import { SendPushByTemplateDto } from './dto/send-push-by-template.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 { 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 { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@@ -25,7 +27,7 @@ export class PushNotificationsController {
@CurrentUser() user: AccessTokenPayload, @CurrentUser() user: AccessTokenPayload,
@Body() registerTokenDto: RegisterDeviceTokenDto, @Body() registerTokenDto: RegisterDeviceTokenDto,
): Promise<RegisterTokenResponseDto> { ): Promise<RegisterTokenResponseDto> {
return this.pushNotificationsService.registerToken(registerTokenDto, user.sub); return this.pushNotificationsService.registerToken(registerTokenDto, user?.sub || '');
} }
@Put('update-token') @Put('update-token')
@@ -37,7 +39,7 @@ export class PushNotificationsController {
@CurrentUser() user: AccessTokenPayload, @CurrentUser() user: AccessTokenPayload,
@Body() updateTokenDto: UpdateDeviceTokenDto, @Body() updateTokenDto: UpdateDeviceTokenDto,
): Promise<UpdateTokenResponseDto> { ): Promise<UpdateTokenResponseDto> {
return this.pushNotificationsService.updateToken(user.sub, updateTokenDto); return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto);
} }
@Delete('unregister-token') @Delete('unregister-token')
@@ -48,7 +50,7 @@ export class PushNotificationsController {
@CurrentUser() user: AccessTokenPayload, @CurrentUser() user: AccessTokenPayload,
@Body() body: { deviceToken: string }, @Body() body: { deviceToken: string },
): Promise<UnregisterTokenResponseDto> { ): Promise<UnregisterTokenResponseDto> {
return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken); return this.pushNotificationsService.unregisterToken(user?.sub || '', body.deviceToken);
} }
@Post('send') @Post('send')
@@ -90,4 +92,24 @@ export class PushNotificationsController {
): Promise<PushResponseDto> { ): Promise<PushResponseDto> {
return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload); 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);
}
} }

View File

@@ -7,6 +7,7 @@ import { ApnsProvider } from './apns.provider';
import { PushTokenService } from './push-token.service'; import { PushTokenService } from './push-token.service';
import { PushTemplateService } from './push-template.service'; import { PushTemplateService } from './push-template.service';
import { PushMessageService } from './push-message.service'; import { PushMessageService } from './push-message.service';
import { PushTestService } from './push-test.service';
import { UserPushToken } from './models/user-push-token.model'; import { UserPushToken } from './models/user-push-token.model';
import { PushMessage } from './models/push-message.model'; import { PushMessage } from './models/push-message.model';
import { PushTemplate } from './models/push-template.model'; import { PushTemplate } from './models/push-template.model';
@@ -35,6 +36,7 @@ import { UsersModule } from '../users/users.module';
PushTokenService, PushTokenService,
PushTemplateService, PushTemplateService,
PushMessageService, PushMessageService,
PushTestService,
], ],
exports: [ exports: [
ApnsProvider, ApnsProvider,
@@ -42,6 +44,7 @@ import { UsersModule } from '../users/users.module';
PushTokenService, PushTokenService,
PushTemplateService, PushTemplateService,
PushMessageService, PushMessageService,
PushTestService,
], ],
}) })
export class PushNotificationsModule { } export class PushNotificationsModule { }

View File

@@ -6,11 +6,13 @@ import { PushTemplateService } from './push-template.service';
import { PushMessageService, CreatePushMessageDto } from './push-message.service'; import { PushMessageService, CreatePushMessageDto } from './push-message.service';
import { SendPushNotificationDto } from './dto/send-push-notification.dto'; import { SendPushNotificationDto } from './dto/send-push-notification.dto';
import { SendPushByTemplateDto } from './dto/send-push-by-template.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 { PushResult, BatchPushResult } from './interfaces/push-notification.interface';
import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto'; import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto';
import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto';
import { ResponseCode } from '../base.dto'; import { ResponseCode } from '../base.dto';
import { PushType } from './enums/push-type.enum';
import { PushMessageStatus } from './enums/push-message-status.enum'; import { PushMessageStatus } from './enums/push-message-status.enum';
import { PushType } from 'apns2';
@Injectable() @Injectable()
export class PushNotificationsService { export class PushNotificationsService {
@@ -402,7 +404,7 @@ export class PushNotificationsService {
title: '', title: '',
body: '', body: '',
payload, payload,
pushType: PushType.BACKGROUND, pushType: PushType.background,
contentAvailable: true, contentAvailable: true,
}; };
@@ -428,6 +430,7 @@ export class PushNotificationsService {
async registerToken(tokenData: any, userId?: string,): Promise<any> { async registerToken(tokenData: any, userId?: string,): Promise<any> {
try { try {
const token = await this.pushTokenService.registerToken(tokenData, userId); const token = await this.pushTokenService.registerToken(tokenData, userId);
this.logger.log(`Registered device token for user ${userId}: ${token.id}`);
return { return {
code: ResponseCode.SUCCESS, code: ResponseCode.SUCCESS,
message: '设备令牌注册成功', 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: [],
},
};
}
}
} }

View 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);
}
}
}

View File

@@ -20,12 +20,11 @@ export class PushTokenService {
*/ */
async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> { async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> {
try { 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({ const existingToken = await this.pushTokenModel.findOne({
where: { where: {
userId,
deviceToken: tokenData.deviceToken, deviceToken: tokenData.deviceToken,
}, },
}); });
@@ -41,7 +40,7 @@ export class PushTokenService {
lastUsedAt: new Date(), 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; return existingToken;
} }
@@ -58,10 +57,10 @@ export class PushTokenService {
lastUsedAt: new Date(), 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; return newToken;
} catch (error) { } 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; throw error;
} }
} }
@@ -322,4 +321,34 @@ export class PushTokenService {
this.logger.error(`Failed to update last used time: ${error.message}`, 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;
}
}
} }