From cc83b84c800f595dc829c84f8de0873684c50656 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 15 Oct 2025 19:09:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(push):=20=E6=96=B0=E5=A2=9E=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8E=A8=E9=80=81=E5=92=8C=E6=B5=8B=E8=AF=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增基于设备令牌的推送通知接口 - 添加推送测试服务,支持应用启动时自动测试 - 新增推送测试文档说明 - 更新 APNS 配置和日志记录 - 迁移至 apns2 库的 PushType 枚举 - 替换订阅密钥文件 - 添加项目规则文档 --- .kilocode/rules/rule.md | 8 + SubscriptionKey_3YKHQZ374P.p8 | 6 + SubscriptionKey_K3L2F8HFTS.p8 | 6 - src/push-notifications/README_PUSH_TEST.md | 132 ++++++++ src/push-notifications/apns.provider.ts | 15 +- .../dto/create-push-template.dto.ts | 2 +- .../dto/device-push-response.dto.ts | 51 +++ .../dto/send-push-notification.dto.ts | 2 +- .../dto/send-push-to-devices.dto.ts | 63 ++++ .../dto/update-push-template.dto.ts | 2 +- .../enums/push-type.enum.ts | 6 - .../interfaces/push-notification.interface.ts | 2 +- .../models/push-message.model.ts | 4 +- .../models/push-template.model.ts | 4 +- .../models/user-push-token.model.ts | 2 +- .../push-notifications.controller.ts | 28 +- .../push-notifications.module.ts | 3 + .../push-notifications.service.ts | 291 +++++++++++++++++- src/push-notifications/push-test.service.ts | 99 ++++++ src/push-notifications/push-token.service.ts | 39 ++- 20 files changed, 728 insertions(+), 37 deletions(-) create mode 100644 .kilocode/rules/rule.md create mode 100644 SubscriptionKey_3YKHQZ374P.p8 delete mode 100644 SubscriptionKey_K3L2F8HFTS.p8 create mode 100644 src/push-notifications/README_PUSH_TEST.md create mode 100644 src/push-notifications/dto/device-push-response.dto.ts create mode 100644 src/push-notifications/dto/send-push-to-devices.dto.ts delete mode 100644 src/push-notifications/enums/push-type.enum.ts create mode 100644 src/push-notifications/push-test.service.ts diff --git a/.kilocode/rules/rule.md b/.kilocode/rules/rule.md new file mode 100644 index 0000000..e678a2f --- /dev/null +++ b/.kilocode/rules/rule.md @@ -0,0 +1,8 @@ +# rule.md + +这是一个 nodejs 基于 nestjs 框架的项目 + +## 指导原则 + +- 不要随意新增 markdown 文档 +- 代码提交 message 用中文 diff --git a/SubscriptionKey_3YKHQZ374P.p8 b/SubscriptionKey_3YKHQZ374P.p8 new file mode 100644 index 0000000..1068aea --- /dev/null +++ b/SubscriptionKey_3YKHQZ374P.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V +UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH +GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F +Db0U/xrS +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/SubscriptionKey_K3L2F8HFTS.p8 b/SubscriptionKey_K3L2F8HFTS.p8 deleted file mode 100644 index 9dadc86..0000000 --- a/SubscriptionKey_K3L2F8HFTS.p8 +++ /dev/null @@ -1,6 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW -4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT -FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb -8U5D2WWu ------END PRIVATE KEY----- \ No newline at end of file diff --git a/src/push-notifications/README_PUSH_TEST.md b/src/push-notifications/README_PUSH_TEST.md new file mode 100644 index 0000000..e786adc --- /dev/null +++ b/src/push-notifications/README_PUSH_TEST.md @@ -0,0 +1,132 @@ +# 推送测试功能 + +本文档介绍如何使用推送测试功能,该功能可以在应用程序启动时自动获取表中的已有 token 进行消息推送。 + +## 功能概述 + +推送测试功能包括以下特性: + +1. **自动测试**:应用程序启动时自动执行推送测试(可通过环境变量控制) +2. **手动触发**:通过 API 接口手动触发推送测试 +3. **统计信息**:获取推送令牌的统计信息 +4. **可配置内容**:可以自定义测试推送的标题和内容 + +## 环境变量配置 + +在 `.env` 文件中添加以下配置: + +```env +# 推送测试配置 +# 启用/禁用应用启动时的推送测试 +ENABLE_PUSH_TEST=false + +# 测试推送消息内容(可选,如果不提供将使用默认值) +PUSH_TEST_TITLE=测试推送 +PUSH_TEST_BODY=这是一条测试推送消息,用于验证推送功能是否正常工作。 +``` + +### 环境变量说明 + +- `ENABLE_PUSH_TEST`: 设置为 `true` 启用应用启动时的推送测试,设置为 `false` 禁用(默认为 `false`) +- `PUSH_TEST_TITLE`: 测试推送的标题(可选) +- `PUSH_TEST_BODY`: 测试推送的内容(可选) + +## API 接口 + +### 1. 手动触发推送测试 + +**请求方式**: `POST` +**请求路径**: `/api/push-test/trigger` + +**响应示例**: +```json +{ + "code": 0, + "message": "Push test completed", + "data": { + "success": true, + "message": "Push test completed" + } +} +``` + +### 2. 获取推送令牌统计信息 + +**请求方式**: `GET` +**请求路径**: `/api/push-test/stats` + +**响应示例**: +```json +{ + "code": 0, + "message": "获取推送令牌统计信息成功", + "data": { + "totalTokens": 100, + "activeTokens": 85, + "inactiveTokens": 15, + "recentlyActiveTokens": 60 + } +} +``` + +## 工作原理 + +1. **自动测试流程**: + - 应用启动时,`PushTestService` 会检查 `ENABLE_PUSH_TEST` 环境变量 + - 如果启用,服务会在应用完全启动后 5 秒执行推送测试 + - 测试会获取最多 10 个活跃的推送令牌 + - 向这些令牌发送测试推送消息 + +2. **手动测试流程**: + - 通过调用 `/api/push-test/trigger` 接口手动触发测试 + - 测试流程与自动测试相同 + +3. **统计信息**: + - `totalTokens`: 总令牌数 + - `activeTokens`: 活跃令牌数 + - `inactiveTokens`: 非活跃令牌数 + - `recentlyActiveTokens`: 最近 7 天活跃的令牌数 + +## 注意事项 + +1. **生产环境使用**: + - 在生产环境中使用前,请确保测试推送内容不会对用户造成困扰 + - 建议在非高峰时段进行测试 + +2. **令牌限制**: + - 自动测试每次最多获取 10 个令牌,避免发送过多推送 + - 只会向标记为 `isActive=true` 的令牌发送推送 + +3. **错误处理**: + - 如果推送失败,服务会记录详细的错误日志 + - 无效的令牌会被自动标记为非活跃状态 + +## 日志记录 + +推送测试功能会记录以下日志: + +- 测试开始和结束 +- 发送成功和失败的统计 +- 每个令牌的推送结果 +- 错误详情 + +这些日志可以帮助排查推送问题。 + +## 示例用法 + +### 启用自动测试 + +1. 在 `.env` 文件中设置 `ENABLE_PUSH_TEST=true` +2. 重启应用程序 +3. 查看日志确认测试是否成功执行 + +### 手动触发测试 + +```bash +curl -X POST http://localhost:3002/api/push-test/trigger +``` + +### 查看统计信息 + +```bash +curl -X GET http://localhost:3002/api/push-test/stats \ No newline at end of file diff --git a/src/push-notifications/apns.provider.ts b/src/push-notifications/apns.provider.ts index dc3219f..f9c9533 100644 --- a/src/push-notifications/apns.provider.ts +++ b/src/push-notifications/apns.provider.ts @@ -77,9 +77,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { keyId, signingKey, defaultTopic: bundleId, - host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com', - port: 443, - production: environment === 'production', + // production: environment === 'production', }; } @@ -88,6 +86,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { */ private async initializeClient(): Promise { try { + this.logger.log(`Initializing APNs Client config: ${JSON.stringify(this.config)}`); this.client = new ApnsClient(this.config); this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`); } catch (error) { @@ -102,7 +101,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { private setupErrorHandlers(): void { // 监听特定错误 this.client.on(Errors.badDeviceToken, (err) => { - this.logger.error(`Bad device token: ${err.deviceToken}`, err.reason); + this.logger.error(`Bad device token: ${err}`, err.reason); }); this.client.on(Errors.unregistered, (err) => { @@ -129,12 +128,14 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { }; try { - this.logger.debug(`Sending notification to ${deviceTokens.length} devices`); for (const deviceToken of deviceTokens) { try { // 为每个设备令牌创建新的通知实例 const deviceNotification = this.createDeviceNotification(notification, deviceToken); + + this.logger.log(`Sending notification to device this.client.send deviceNotification ${JSON.stringify(deviceNotification, null, 2)}`); + await this.client.send(deviceNotification); results.sent.push(deviceToken); } catch (error) { @@ -332,7 +333,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy { */ private createDeviceNotification(notification: Notification, deviceToken: string): Notification { // 创建新的通知实例,使用相同的选项但不同的设备令牌 - return new Notification(deviceToken, notification.options); + return new Notification(deviceToken, { + alert: notification.options.alert, + }); } /** diff --git a/src/push-notifications/dto/create-push-template.dto.ts b/src/push-notifications/dto/create-push-template.dto.ts index c59c22d..c7187e2 100644 --- a/src/push-notifications/dto/create-push-template.dto.ts +++ b/src/push-notifications/dto/create-push-template.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export class CreatePushTemplateDto { @ApiProperty({ description: '模板键' }) diff --git a/src/push-notifications/dto/device-push-response.dto.ts b/src/push-notifications/dto/device-push-response.dto.ts new file mode 100644 index 0000000..0b82588 --- /dev/null +++ b/src/push-notifications/dto/device-push-response.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ResponseCode } from '../../base.dto'; + +export class DevicePushResult { + @ApiProperty({ description: '设备令牌' }) + deviceToken: string; + + @ApiProperty({ description: '用户ID(可选,如果可获取)' }) + userId?: string; + + @ApiProperty({ description: '是否成功' }) + success: boolean; + + @ApiProperty({ description: '错误信息', required: false }) + error?: string; + + @ApiProperty({ description: 'APNs响应', required: false }) + apnsResponse?: any; +} + +export class DevicePushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '推送结果' }) + data: { + success: boolean; + sentCount: number; + failedCount: number; + results: DevicePushResult[]; + }; +} + +export class BatchDevicePushResponseDto { + @ApiProperty({ description: '响应代码' }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息' }) + message: string; + + @ApiProperty({ description: '批量推送结果' }) + data: { + totalTokens: number; + successCount: number; + failedCount: number; + results: DevicePushResult[]; + }; +} \ No newline at end of file diff --git a/src/push-notifications/dto/send-push-notification.dto.ts b/src/push-notifications/dto/send-push-notification.dto.ts index 39feaae..3cbc958 100644 --- a/src/push-notifications/dto/send-push-notification.dto.ts +++ b/src/push-notifications/dto/send-push-notification.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export class SendPushNotificationDto { @ApiProperty({ description: '用户ID列表' }) diff --git a/src/push-notifications/dto/send-push-to-devices.dto.ts b/src/push-notifications/dto/send-push-to-devices.dto.ts new file mode 100644 index 0000000..6ad99fb --- /dev/null +++ b/src/push-notifications/dto/send-push-to-devices.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator'; +import { PushType } from 'apns2'; + +export class SendPushToDevicesDto { + @ApiProperty({ description: '设备令牌列表' }) + @IsArray() + @IsString({ each: true }) + deviceTokens: string[]; + + @ApiProperty({ description: '推送标题' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: '推送内容' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiProperty({ description: '自定义数据', required: false }) + @IsObject() + @IsOptional() + payload?: any; + + @ApiProperty({ description: '推送类型', enum: PushType, required: false }) + @IsEnum(PushType) + @IsOptional() + pushType?: PushType; + + @ApiProperty({ description: '优先级', required: false }) + @IsNumber() + @IsOptional() + priority?: number; + + @ApiProperty({ description: '过期时间(秒)', required: false }) + @IsNumber() + @IsOptional() + expiry?: number; + + @ApiProperty({ description: '折叠ID', required: false }) + @IsString() + @IsOptional() + collapseId?: string; + + @ApiProperty({ description: '声音', required: false }) + @IsString() + @IsOptional() + sound?: string; + + @ApiProperty({ description: '徽章数', required: false }) + @IsNumber() + @IsOptional() + badge?: number; + + @ApiProperty({ description: '是否可变内容', required: false }) + @IsOptional() + mutableContent?: boolean; + + @ApiProperty({ description: '是否静默推送', required: false }) + @IsOptional() + contentAvailable?: boolean; +} \ No newline at end of file diff --git a/src/push-notifications/dto/update-push-template.dto.ts b/src/push-notifications/dto/update-push-template.dto.ts index 7b7ba3d..17ed5a7 100644 --- a/src/push-notifications/dto/update-push-template.dto.ts +++ b/src/push-notifications/dto/update-push-template.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; +import { PushType } from 'apns2'; import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator'; -import { PushType } from '../enums/push-type.enum'; export class UpdatePushTemplateDto { @ApiProperty({ description: '模板标题', required: false }) diff --git a/src/push-notifications/enums/push-type.enum.ts b/src/push-notifications/enums/push-type.enum.ts deleted file mode 100644 index d3b00a5..0000000 --- a/src/push-notifications/enums/push-type.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum PushType { - ALERT = 'ALERT', - BACKGROUND = 'BACKGROUND', - VOIP = 'VOIP', - LIVEACTIVITY = 'LIVEACTIVITY', -} \ No newline at end of file diff --git a/src/push-notifications/interfaces/push-notification.interface.ts b/src/push-notifications/interfaces/push-notification.interface.ts index f3ea3ac..036b27c 100644 --- a/src/push-notifications/interfaces/push-notification.interface.ts +++ b/src/push-notifications/interfaces/push-notification.interface.ts @@ -1,4 +1,4 @@ -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; export interface PushNotificationRequest { userIds: string[]; diff --git a/src/push-notifications/models/push-message.model.ts b/src/push-notifications/models/push-message.model.ts index 69ffd98..8858d0a 100644 --- a/src/push-notifications/models/push-message.model.ts +++ b/src/push-notifications/models/push-message.model.ts @@ -1,6 +1,6 @@ import { Column, Model, Table, DataType, Index } from 'sequelize-typescript'; -import { PushType } from '../enums/push-type.enum'; import { PushMessageStatus } from '../enums/push-message-status.enum'; +import { PushType } from 'apns2'; @Table({ tableName: 't_push_messages', @@ -77,7 +77,7 @@ export class PushMessage extends Model { @Column({ type: DataType.ENUM(...Object.values(PushType)), allowNull: false, - defaultValue: PushType.ALERT, + defaultValue: PushType.alert, comment: '推送类型', }) declare pushType: PushType; diff --git a/src/push-notifications/models/push-template.model.ts b/src/push-notifications/models/push-template.model.ts index e935bae..166480e 100644 --- a/src/push-notifications/models/push-template.model.ts +++ b/src/push-notifications/models/push-template.model.ts @@ -1,5 +1,5 @@ import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript'; -import { PushType } from '../enums/push-type.enum'; +import { PushType } from 'apns2'; @Table({ tableName: 't_push_templates', @@ -58,7 +58,7 @@ export class PushTemplate extends Model { @Column({ type: DataType.ENUM(...Object.values(PushType)), allowNull: false, - defaultValue: PushType.ALERT, + defaultValue: PushType.alert, field: 'push_type', comment: '推送类型', }) diff --git a/src/push-notifications/models/user-push-token.model.ts b/src/push-notifications/models/user-push-token.model.ts index 2eed7a2..d435443 100644 --- a/src/push-notifications/models/user-push-token.model.ts +++ b/src/push-notifications/models/user-push-token.model.ts @@ -30,7 +30,7 @@ export class UserPushToken extends Model { @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, comment: '用户ID', }) declare userId: string; diff --git a/src/push-notifications/push-notifications.controller.ts b/src/push-notifications/push-notifications.controller.ts index 1c51166..197a3c1 100644 --- a/src/push-notifications/push-notifications.controller.ts +++ b/src/push-notifications/push-notifications.controller.ts @@ -5,7 +5,9 @@ import { RegisterDeviceTokenDto } from './dto/register-device-token.dto'; import { UpdateDeviceTokenDto } from './dto/update-device-token.dto'; import { SendPushNotificationDto } from './dto/send-push-notification.dto'; import { SendPushByTemplateDto } from './dto/send-push-by-template.dto'; +import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto'; import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto'; +import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-push-response.dto'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; @@ -25,7 +27,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() registerTokenDto: RegisterDeviceTokenDto, ): Promise { - return this.pushNotificationsService.registerToken(registerTokenDto, user.sub); + return this.pushNotificationsService.registerToken(registerTokenDto, user?.sub || ''); } @Put('update-token') @@ -37,7 +39,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() updateTokenDto: UpdateDeviceTokenDto, ): Promise { - return this.pushNotificationsService.updateToken(user.sub, updateTokenDto); + return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto); } @Delete('unregister-token') @@ -48,7 +50,7 @@ export class PushNotificationsController { @CurrentUser() user: AccessTokenPayload, @Body() body: { deviceToken: string }, ): Promise { - return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken); + return this.pushNotificationsService.unregisterToken(user?.sub || '', body.deviceToken); } @Post('send') @@ -90,4 +92,24 @@ export class PushNotificationsController { ): Promise { return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload); } + + @Post('send-to-devices') + @ApiOperation({ summary: '向指定设备发送推送通知' }) + @UseGuards(JwtAuthGuard) + @ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto }) + async sendNotificationToDevices( + @Body() sendToDevicesDto: SendPushToDevicesDto, + ): Promise { + return this.pushNotificationsService.sendNotificationToDevices(sendToDevicesDto); + } + + @Post('send-batch-to-devices') + @ApiOperation({ summary: '批量向指定设备发送推送通知' }) + @UseGuards(JwtAuthGuard) + @ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto }) + async sendBatchNotificationToDevices( + @Body() sendBatchToDevicesDto: SendPushToDevicesDto, + ): Promise { + return this.pushNotificationsService.sendBatchNotificationToDevices(sendBatchToDevicesDto); + } } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts index ee45cfd..56e6945 100644 --- a/src/push-notifications/push-notifications.module.ts +++ b/src/push-notifications/push-notifications.module.ts @@ -7,6 +7,7 @@ import { ApnsProvider } from './apns.provider'; import { PushTokenService } from './push-token.service'; import { PushTemplateService } from './push-template.service'; import { PushMessageService } from './push-message.service'; +import { PushTestService } from './push-test.service'; import { UserPushToken } from './models/user-push-token.model'; import { PushMessage } from './models/push-message.model'; import { PushTemplate } from './models/push-template.model'; @@ -35,6 +36,7 @@ import { UsersModule } from '../users/users.module'; PushTokenService, PushTemplateService, PushMessageService, + PushTestService, ], exports: [ ApnsProvider, @@ -42,6 +44,7 @@ import { UsersModule } from '../users/users.module'; PushTokenService, PushTemplateService, PushMessageService, + PushTestService, ], }) export class PushNotificationsModule { } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.service.ts b/src/push-notifications/push-notifications.service.ts index fa0d8d8..1bd9b24 100644 --- a/src/push-notifications/push-notifications.service.ts +++ b/src/push-notifications/push-notifications.service.ts @@ -6,11 +6,13 @@ import { PushTemplateService } from './push-template.service'; import { PushMessageService, CreatePushMessageDto } from './push-message.service'; import { SendPushNotificationDto } from './dto/send-push-notification.dto'; import { SendPushByTemplateDto } from './dto/send-push-by-template.dto'; +import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto'; import { PushResult, BatchPushResult } from './interfaces/push-notification.interface'; import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto'; +import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto'; import { ResponseCode } from '../base.dto'; -import { PushType } from './enums/push-type.enum'; import { PushMessageStatus } from './enums/push-message-status.enum'; +import { PushType } from 'apns2'; @Injectable() export class PushNotificationsService { @@ -402,7 +404,7 @@ export class PushNotificationsService { title: '', body: '', payload, - pushType: PushType.BACKGROUND, + pushType: PushType.background, contentAvailable: true, }; @@ -428,6 +430,7 @@ export class PushNotificationsService { async registerToken(tokenData: any, userId?: string,): Promise { try { const token = await this.pushTokenService.registerToken(tokenData, userId); + this.logger.log(`Registered device token for user ${userId}: ${token.id}`); return { code: ResponseCode.SUCCESS, message: '设备令牌注册成功', @@ -500,4 +503,288 @@ export class PushNotificationsService { }; } } + + /** + * 基于设备令牌发送推送通知 + */ + async sendNotificationToDevices(notificationData: SendPushToDevicesDto): Promise { + try { + this.logger.log(`Sending push notification to ${notificationData.deviceTokens.length} devices`); + + const results: DevicePushResult[] = []; + let sentCount = 0; + let failedCount = 0; + + // 为每个设备令牌创建消息记录并发送推送 + for (const deviceToken of notificationData.deviceTokens) { + try { + // 尝试获取设备令牌对应的用户ID + const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken); + + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId: userId || '', + deviceToken, + messageType: 'manual', + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined, + collapseId: notificationData.collapseId, + }; + + const message = await this.pushMessageService.createMessage(messageData); + + // 创建APNs通知 + const apnsNotification = this.apnsProvider.createNotification({ + title: notificationData.title, + body: notificationData.body, + data: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry, + collapseId: notificationData.collapseId, + topic: this.bundleId, + sound: notificationData.sound, + badge: notificationData.badge, + mutableContent: notificationData.mutableContent, + contentAvailable: notificationData.contentAvailable, + }); + + // 发送推送 + const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]); + + // 处理结果 + if (apnsResults.sent.length > 0) { + await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults); + await this.pushTokenService.updateLastUsedTime(deviceToken); + results.push({ + deviceToken, + userId: userId || undefined, + success: true, + apnsResponse: apnsResults, + }); + sentCount++; + } else { + const failure = apnsResults.failed[0]; + const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; + + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + failure.response, + errorMessage + ); + + // 如果是无效令牌,停用该令牌 + if (failure.status === '410' || failure.response?.reason === 'Unregistered') { + if (userId) { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } else { + // 如果没有用户ID,直接停用令牌 + await this.pushTokenService.deactivateToken(deviceToken); + } + } + + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to send push to device ${deviceToken}: ${error.message}`, error); + results.push({ + deviceToken, + success: false, + error: error.message, + }); + failedCount++; + } + } + + const success = failedCount === 0; + + return { + code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR, + message: success ? '推送发送成功' : '部分推送发送失败', + data: { + success, + sentCount, + failedCount, + results, + }, + }; + } catch (error) { + this.logger.error(`Failed to send push notification to devices: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `推送发送失败: ${error.message}`, + data: { + success: false, + sentCount: 0, + failedCount: notificationData.deviceTokens.length, + results: [], + }, + }; + } + } + + /** + * 批量基于设备令牌发送推送通知 + */ + async sendBatchNotificationToDevices(notificationData: SendPushToDevicesDto): Promise { + try { + this.logger.log(`Sending batch push notification to ${notificationData.deviceTokens.length} devices`); + + const results: DevicePushResult[] = []; + let totalTokens = notificationData.deviceTokens.length; + let successCount = 0; + let failedCount = 0; + + // 创建APNs通知 + const apnsNotification = this.apnsProvider.createNotification({ + alert: notificationData.title, + title: notificationData.title, + body: notificationData.body, + data: notificationData.payload, + pushType: notificationData.pushType, + topic: this.bundleId, + sound: notificationData.sound, + badge: notificationData.badge, + }); + + this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`); + + // 批量发送推送 + const apnsResults = await this.apnsProvider.send(apnsNotification, notificationData.deviceTokens); + + // 处理结果并创建消息记录 + for (const deviceToken of notificationData.deviceTokens) { + try { + // 尝试获取设备令牌对应的用户ID + const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken); + + // 创建消息记录 + const messageData: CreatePushMessageDto = { + userId: userId || '', + deviceToken, + messageType: 'batch', + title: notificationData.title, + body: notificationData.body, + payload: notificationData.payload, + pushType: notificationData.pushType, + priority: notificationData.priority, + expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined, + collapseId: notificationData.collapseId, + }; + + const message = await this.pushMessageService.createMessage(messageData); + + // 查找对应的APNs结果 + const apnsResult = apnsResults.sent.includes(deviceToken) ? + { device: deviceToken, success: true } : + apnsResults.failed.find(f => f.device === deviceToken); + + if (apnsResult) { + if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) { + // 成功发送 + await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult); + await this.pushTokenService.updateLastUsedTime(deviceToken); + results.push({ + deviceToken, + userId: userId || undefined, + success: true, + apnsResponse: apnsResult, + }); + successCount++; + } else { + // 发送失败 + const failure = apnsResult as any; + const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; + + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + failure.response, + errorMessage + ); + + // 如果是无效令牌,停用该令牌 + if (failure.status === '410' || failure.response?.reason === 'Unregistered') { + if (userId) { + await this.pushTokenService.unregisterToken(userId, deviceToken); + } else { + // 如果没有用户ID,直接停用令牌 + await this.pushTokenService.deactivateToken(deviceToken); + } + } + + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: errorMessage, + apnsResponse: failure.response, + }); + failedCount++; + } + } else { + // 未找到结果,标记为失败 + await this.pushMessageService.updateMessageStatus( + message.id, + PushMessageStatus.FAILED, + null, + 'No APNs result found' + ); + results.push({ + deviceToken, + userId: userId || undefined, + success: false, + error: 'No APNs result found', + }); + failedCount++; + } + } catch (error) { + this.logger.error(`Failed to process batch push result for device ${deviceToken}: ${error.message}`, error); + results.push({ + deviceToken, + success: false, + error: error.message, + }); + failedCount++; + } + } + + const success = failedCount === 0; + + return { + code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR, + message: success ? '批量推送发送成功' : '部分批量推送发送失败', + data: { + totalTokens, + successCount, + failedCount, + results, + }, + }; + } catch (error) { + this.logger.error(`Failed to send batch push notification to devices: ${error.message}`, error); + return { + code: ResponseCode.ERROR, + message: `批量推送发送失败: ${error.message}`, + data: { + totalTokens: notificationData.deviceTokens.length, + successCount: 0, + failedCount: notificationData.deviceTokens.length, + results: [], + }, + }; + } + } } \ No newline at end of file diff --git a/src/push-notifications/push-test.service.ts b/src/push-notifications/push-test.service.ts new file mode 100644 index 0000000..1ef491a --- /dev/null +++ b/src/push-notifications/push-test.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PushNotificationsService } from './push-notifications.service'; +import { PushTokenService } from './push-token.service'; +import { UserPushToken } from './models/user-push-token.model'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op } from 'sequelize'; +import { PushType } from 'apns2'; + +@Injectable() +export class PushTestService implements OnModuleInit { + private readonly logger = new Logger(PushTestService.name); + + constructor( + @InjectModel(UserPushToken) + private readonly pushTokenModel: typeof UserPushToken, + private readonly pushNotificationsService: PushNotificationsService, + private readonly pushTokenService: PushTokenService, + private readonly configService: ConfigService, + ) { } + + /** + * 模块初始化时执行 + */ + async onModuleInit() { + // 检查是否启用推送测试 + const enablePushTest = this.configService.get('ENABLE_PUSH_TEST', false); + + if (!enablePushTest) { + this.logger.log('Push test is disabled. Skipping...'); + return; + } + + // 延迟执行,确保应用完全启动 + setTimeout(async () => { + try { + await this.performPushTest(); + } catch (error) { + this.logger.error(`Push test failed: ${error.message}`, error); + } + }, 5000); // 5秒后执行 + } + + /** + * 执行推送测试 + */ + private async performPushTest(): Promise { + this.logger.log('Starting push test...'); + + try { + // 获取所有活跃的推送令牌 + const activeTokens = await this.pushTokenModel.findAll({ + where: { + isActive: true, + }, + limit: 10, // 限制测试数量,避免发送过多推送 + }); + + if (activeTokens.length === 0) { + this.logger.log('No active push tokens found for testing'); + return; + } + + this.logger.log(`Found ${activeTokens.length} active tokens for testing`); + + // 准备测试推送内容 + const testTitle = this.configService.get('PUSH_TEST_TITLE', '测试推送'); + const testBody = this.configService.get('PUSH_TEST_BODY', '这是一条测试推送消息,用于验证推送功能是否正常工作。'); + + // 发送测试推送 + const result = await this.pushNotificationsService.sendBatchNotificationToDevices({ + deviceTokens: activeTokens.map(token => token.deviceToken), + title: testTitle, + body: testBody, + pushType: PushType.alert, + }); + + if (result.code === 0) { + this.logger.log(`Push test completed successfully. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`); + } else { + this.logger.warn(`Push test completed with issues. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`); + } + + // 记录详细结果 + if (result.data.results && result.data.results.length > 0) { + result.data.results.forEach((resultItem, index) => { + if (resultItem.success) { + this.logger.log(`Push test success for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...`); + } else { + this.logger.warn(`Push test failed for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...: ${resultItem.error}`); + } + }); + } + } catch (error) { + this.logger.error(`Error during push test: ${error.message}`, error); + } + } + +} \ No newline at end of file diff --git a/src/push-notifications/push-token.service.ts b/src/push-notifications/push-token.service.ts index fa6ae30..ee0469f 100644 --- a/src/push-notifications/push-token.service.ts +++ b/src/push-notifications/push-token.service.ts @@ -20,12 +20,11 @@ export class PushTokenService { */ async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise { try { - this.logger.log(`Registering push token for user ${userId}`); + this.logger.log(`Registering push token for device ${tokenData.deviceToken}`); // 检查是否已存在相同的令牌 const existingToken = await this.pushTokenModel.findOne({ where: { - userId, deviceToken: tokenData.deviceToken, }, }); @@ -41,7 +40,7 @@ export class PushTokenService { lastUsedAt: new Date(), }); - this.logger.log(`Updated existing push token for user ${userId}`); + this.logger.log(`Updated existing push token for device ${tokenData.deviceToken}`); return existingToken; } @@ -58,10 +57,10 @@ export class PushTokenService { lastUsedAt: new Date(), }); - this.logger.log(`Successfully registered new push token for user ${userId}`); + this.logger.log(`Successfully registered new push token for device ${tokenData.deviceToken}`); return newToken; } catch (error) { - this.logger.error(`Failed to register push token for user ${userId}: ${error.message}`, error); + this.logger.error(`Failed to register push token for device ${tokenData.deviceToken}: ${error.message}`, error); throw error; } } @@ -322,4 +321,34 @@ export class PushTokenService { this.logger.error(`Failed to update last used time: ${error.message}`, error); } } + + /** + * 直接停用设备令牌(不需要用户ID) + */ + async deactivateToken(deviceToken: string): Promise { + try { + this.logger.log(`Deactivating push token: ${deviceToken}`); + + const token = await this.pushTokenModel.findOne({ + where: { + deviceToken, + isActive: true, + }, + }); + + if (!token) { + this.logger.warn(`Device token not found or already inactive: ${deviceToken}`); + return; + } + + await token.update({ + isActive: false, + }); + + this.logger.log(`Successfully deactivated push token: ${deviceToken}`); + } catch (error) { + this.logger.error(`Failed to deactivate push token: ${deviceToken}: ${error.message}`, error); + throw error; + } + } } \ No newline at end of file