feat(push): migrate APNs provider from @parse/node-apn to apns2 library

- Replace @parse/node-apn with apns2 for improved APNs integration
- Update ApnsProvider to use new ApnsClient with modern API
- Refactor notification creation and sending logic for better error handling
- Add proper error event listeners for device token issues
- Update configuration interface to match apns2 requirements
- Modify push notification endpoints to allow public access for token registration
- Update service methods to handle new response format from apns2
- Add UsersModule dependency to PushNotificationsModule
This commit is contained in:
richarjiang
2025-10-14 19:25:30 +08:00
parent 305a969912
commit 38dd740c8c
9 changed files with 520 additions and 171 deletions

View File

@@ -1,15 +1,23 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as apn from '@parse/node-apn';
import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2';
import * as fs from 'fs';
import * as path from 'path';
import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface';
interface SendResult {
sent: string[];
failed: Array<{
device: string;
error?: Error;
status?: string;
response?: any;
}>;
}
@Injectable()
export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ApnsProvider.name);
private provider: apn.Provider;
private multiProvider: apn.MultiProvider;
private client: ApnsClient;
private config: ApnsConfig;
constructor(private readonly configService: ConfigService) {
@@ -18,7 +26,8 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
try {
await this.initializeProvider();
await this.initializeClient();
this.setupErrorHandlers();
this.logger.log('APNs Provider initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize APNs Provider', error);
@@ -28,7 +37,7 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
async onModuleDestroy() {
try {
this.shutdown();
await this.shutdown();
this.logger.log('APNs Provider shutdown successfully');
} catch (error) {
this.logger.error('Error during APNs Provider shutdown', error);
@@ -39,25 +48,24 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
* 构建APNs配置
*/
private buildConfig(): ApnsConfig {
const keyId = this.configService.get<string>('APNS_KEY_ID');
const teamId = this.configService.get<string>('APNS_TEAM_ID');
const keyId = this.configService.get<string>('APNS_KEY_ID');
const keyPath = this.configService.get<string>('APNS_KEY_PATH');
const bundleId = this.configService.get<string>('APNS_BUNDLE_ID');
const environment = this.configService.get<string>('APNS_ENVIRONMENT', 'sandbox');
const clientCount = this.configService.get<number>('APNS_CLIENT_COUNT', 2);
if (!keyId || !teamId || !keyPath || !bundleId) {
if (!teamId || !keyId || !keyPath || !bundleId) {
throw new Error('Missing required APNs configuration');
}
let key: string | Buffer;
let signingKey: string | Buffer;
try {
// 尝试读取密钥文件
if (fs.existsSync(keyPath)) {
key = fs.readFileSync(keyPath);
signingKey = fs.readFileSync(keyPath);
} else {
// 如果是直接的内容而不是文件路径
key = keyPath;
signingKey = keyPath;
}
} catch (error) {
this.logger.error(`Failed to read APNs key file: ${keyPath}`, error);
@@ -65,49 +73,79 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
}
return {
token: {
key,
keyId,
teamId,
},
team: teamId,
keyId,
signingKey,
defaultTopic: bundleId,
host: environment === 'production' ? 'api.push.apple.com' : 'api.development.push.apple.com',
port: 443,
production: environment === 'production',
clientCount,
connectionRetryLimit: this.configService.get<number>('APNS_CONNECTION_RETRY_LIMIT', 3),
heartBeat: this.configService.get<number>('APNS_HEARTBEAT', 60000),
requestTimeout: this.configService.get<number>('APNS_REQUEST_TIMEOUT', 5000),
};
}
/**
* 初始化APNs连接
* 初始化APNs客户端
*/
private async initializeProvider(): Promise<void> {
private async initializeClient(): Promise<void> {
try {
// 创建单个Provider
this.provider = new apn.Provider(this.config);
// 创建多Provider连接池
this.multiProvider = new apn.MultiProvider(this.config);
this.logger.log(`APNs Provider initialized with ${this.config.clientCount} clients`);
this.logger.log(`Environment: ${this.config.production ? 'Production' : 'Sandbox'}`);
this.client = new ApnsClient(this.config);
this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`);
} catch (error) {
this.logger.error('Failed to initialize APNs Provider', error);
this.logger.error('Failed to initialize APNs Client', error);
throw error;
}
}
/**
* 设置错误处理器
*/
private setupErrorHandlers(): void {
// 监听特定错误
this.client.on(Errors.badDeviceToken, (err) => {
this.logger.error(`Bad device token: ${err.deviceToken}`, err.reason);
});
this.client.on(Errors.unregistered, (err) => {
this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason);
});
this.client.on(Errors.topicDisallowed, (err) => {
this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason);
});
// 监听所有错误
this.client.on(Errors.error, (err) => {
this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err);
});
}
/**
* 发送单个通知
*/
async send(notification: apn.Notification, deviceTokens: string[]): Promise<apn.Results> {
async send(notification: Notification, deviceTokens: string[]): Promise<SendResult> {
const results: SendResult = {
sent: [],
failed: []
};
try {
this.logger.debug(`Sending notification to ${deviceTokens.length} devices`);
const results = await this.provider.send(notification, deviceTokens);
for (const deviceToken of deviceTokens) {
try {
// 为每个设备令牌创建新的通知实例
const deviceNotification = this.createDeviceNotification(notification, deviceToken);
await this.client.send(deviceNotification);
results.sent.push(deviceToken);
} catch (error) {
results.failed.push({
device: deviceToken,
error: error as Error
});
}
}
this.logResults(results);
return results;
} catch (error) {
this.logger.error('Error sending notification', error);
@@ -118,14 +156,40 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 批量发送通知
*/
async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise<apn.Results> {
async sendBatch(notifications: Notification[], deviceTokens: string[]): Promise<SendResult> {
const results: SendResult = {
sent: [],
failed: []
};
try {
this.logger.debug(`Sending ${notifications.length} notifications to ${deviceTokens.length} devices`);
const results = await this.multiProvider.send(notifications, deviceTokens);
const deviceNotifications: Notification[] = [];
for (const notification of notifications) {
for (const deviceToken of deviceTokens) {
deviceNotifications.push(this.createDeviceNotification(notification, deviceToken));
}
}
const sendResults = await this.client.sendMany(deviceNotifications);
// 处理 sendMany 的结果
sendResults.forEach((result, index) => {
const deviceIndex = index % deviceTokens.length;
const deviceToken = deviceTokens[deviceIndex];
if (result && typeof result === 'object' && 'error' in result) {
results.failed.push({
device: deviceToken,
error: (result as any).error
});
} else {
results.sent.push(deviceToken);
}
});
this.logResults(results);
return results;
} catch (error) {
this.logger.error('Error sending batch notifications', error);
@@ -136,15 +200,15 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 管理推送通道
*/
async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise<any> {
async manageChannels(notification: Notification, bundleId: string, action: string): Promise<any> {
try {
this.logger.debug(`Managing channels for bundle ${bundleId} with action ${action}`);
const results = await this.provider.manageChannels(notification, bundleId, action);
// apns2 库没有直接的 manageChannels 方法,这里需要实现自定义逻辑
// 或者使用原始的 HTTP 请求来管理通道
this.logger.warn(`Channel management not directly supported in apns2 library. Action: ${action}`);
this.logger.log(`Channel management completed: ${JSON.stringify(results)}`);
return results;
return { message: 'Channel management not implemented in apns2 library' };
} catch (error) {
this.logger.error('Error managing channels', error);
throw error;
@@ -154,15 +218,15 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 广播实时活动通知
*/
async broadcast(notification: apn.Notification, bundleId: string): Promise<any> {
async broadcast(notification: Notification, bundleId: string): Promise<any> {
try {
this.logger.debug(`Broadcasting to bundle ${bundleId}`);
const results = await this.provider.broadcast(notification, bundleId);
// apns2 库没有直接的 broadcast 方法,这里需要实现自定义逻辑
// 或者使用原始的 HTTP 请求来广播
this.logger.warn(`Broadcast not directly supported in apns2 library. Bundle: ${bundleId}`);
this.logger.log(`Broadcast completed: ${JSON.stringify(results)}`);
return results;
return { message: 'Broadcast not implemented in apns2 library' };
} catch (error) {
this.logger.error('Error broadcasting', error);
throw error;
@@ -172,88 +236,109 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 创建标准通知
*/
createNotification(options: {
title?: string;
body?: string;
payload?: any;
pushType?: string;
priority?: number;
expiry?: number;
collapseId?: string;
topic?: string;
sound?: string;
badge?: number;
mutableContent?: boolean;
contentAvailable?: boolean;
}): apn.Notification {
const notification = new apn.Notification();
createNotification(options: ApnsNotificationOptions): Notification {
// 构建通知选项
const notificationOptions: any = {};
// 设置基本内容
if (options.title) {
notification.title = options.title;
// 设置 APS 属性
const aps: any = {};
if (options.badge !== undefined) {
notificationOptions.badge = options.badge;
}
if (options.body) {
notification.body = options.body;
}
// 设置自定义负载
if (options.payload) {
notification.payload = options.payload;
}
// 设置推送类型
if (options.pushType) {
notification.pushType = options.pushType;
}
// 设置优先级
if (options.priority) {
notification.priority = options.priority;
}
// 设置过期时间
if (options.expiry) {
notification.expiry = options.expiry;
}
// 设置折叠ID
if (options.collapseId) {
notification.collapseId = options.collapseId;
}
// 设置主题
if (options.topic) {
notification.topic = options.topic;
}
// 设置声音
if (options.sound) {
notification.sound = options.sound;
notificationOptions.sound = options.sound;
}
// 设置徽章
if (options.badge) {
notification.badge = options.badge;
}
// 设置可变内容
if (options.mutableContent) {
notification.mutableContent = 1;
}
// 设置静默推送
if (options.contentAvailable) {
notification.contentAvailable = 1;
notificationOptions.contentAvailable = true;
}
return notification;
if (options.mutableContent) {
notificationOptions.mutableContent = true;
}
if (options.priority) {
notificationOptions.priority = options.priority;
}
if (options.pushType) {
notificationOptions.type = options.pushType;
}
// 添加自定义数据
if (options.data) {
notificationOptions.data = options.data;
}
// 创建通知对象,但不指定设备令牌(将在发送时设置)
return new Notification('', notificationOptions);
}
/**
* 创建基本通知
*/
createBasicNotification(deviceToken: string, title: string, body?: string, options?: Partial<ApnsNotificationOptions>): Notification {
// 构建通知选项
const notificationOptions: any = {
alert: {
title,
body: body || ''
}
};
if (options?.badge !== undefined) {
notificationOptions.badge = options.badge;
}
if (options?.sound) {
notificationOptions.sound = options.sound;
}
if (options?.contentAvailable) {
notificationOptions.contentAvailable = true;
}
if (options?.mutableContent) {
notificationOptions.mutableContent = true;
}
if (options?.priority) {
notificationOptions.priority = options.priority;
}
if (options?.pushType) {
notificationOptions.type = options.pushType;
}
// 添加自定义数据
if (options?.data) {
notificationOptions.data = options.data;
}
return new Notification(deviceToken, notificationOptions);
}
/**
* 创建静默通知
*/
createSilentNotification(deviceToken: string): SilentNotification {
return new SilentNotification(deviceToken);
}
/**
* 为特定设备创建通知实例
*/
private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
// 创建新的通知实例,使用相同的选项但不同的设备令牌
return new Notification(deviceToken, notification.options);
}
/**
* 记录推送结果
*/
private logResults(results: apn.Results): void {
private logResults(results: SendResult): void {
const { sent, failed } = results;
this.logger.log(`Push results: ${sent.length} sent, ${failed.length} failed`);
@@ -272,14 +357,10 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 关闭连接
*/
shutdown(): void {
async shutdown(): Promise<void> {
try {
if (this.provider) {
this.provider.shutdown();
}
if (this.multiProvider) {
this.multiProvider.shutdown();
if (this.client) {
await this.client.close();
}
this.logger.log('APNs Provider connections closed');
@@ -291,10 +372,9 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/**
* 获取Provider状态
*/
getStatus(): { connected: boolean; clientCount: number; environment: string } {
getStatus(): { connected: boolean; environment: string } {
return {
connected: !!(this.provider || this.multiProvider),
clientCount: this.config.clientCount || 1,
connected: !!this.client,
environment: this.config.production ? 'production' : 'sandbox',
};
}

View File

@@ -1,25 +1,26 @@
export interface ApnsConfig {
token: {
key: string | Buffer;
keyId: string;
teamId: string;
};
production: boolean;
clientCount?: number;
proxy?: {
host: string;
port: number;
};
connectionRetryLimit?: number;
heartBeat?: number;
requestTimeout?: number;
team: string;
keyId: string;
signingKey: string | Buffer;
defaultTopic: string;
host?: string;
port?: number;
production?: boolean;
}
export interface ApnsNotificationOptions {
topic: string;
topic?: string;
id?: string;
collapseId?: string;
priority?: number;
pushType?: string;
expiry?: number;
badge?: number;
sound?: string;
contentAvailable?: boolean;
mutableContent?: boolean;
data?: Record<string, any>;
title?: string;
body?: string;
alert?: any;
}

View File

@@ -13,21 +13,24 @@ import { Public } from '../common/decorators/public.decorator';
@ApiTags('推送通知')
@Controller('push-notifications')
@UseGuards(JwtAuthGuard)
export class PushNotificationsController {
constructor(private readonly pushNotificationsService: PushNotificationsService) { }
@Post('register-token')
@ApiOperation({ summary: '注册设备推送令牌' })
@Public()
@ApiResponse({ status: 200, description: '注册成功', type: RegisterTokenResponseDto })
async registerToken(
@CurrentUser() user: AccessTokenPayload,
@Body() registerTokenDto: RegisterDeviceTokenDto,
): Promise<RegisterTokenResponseDto> {
return this.pushNotificationsService.registerToken(user.sub, registerTokenDto);
return this.pushNotificationsService.registerToken(registerTokenDto, user.sub);
}
@Put('update-token')
@Public()
@ApiOperation({ summary: '更新设备推送令牌' })
@ApiResponse({ status: 200, description: '更新成功', type: UpdateTokenResponseDto })
async updateToken(
@@ -38,6 +41,7 @@ export class PushNotificationsController {
}
@Delete('unregister-token')
@Public()
@ApiOperation({ summary: '注销设备推送令牌' })
@ApiResponse({ status: 200, description: '注销成功', type: UnregisterTokenResponseDto })
async unregisterToken(
@@ -49,6 +53,7 @@ export class PushNotificationsController {
@Post('send')
@ApiOperation({ summary: '发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto,
@@ -58,6 +63,7 @@ export class PushNotificationsController {
@Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto,
@@ -67,6 +73,7 @@ export class PushNotificationsController {
@Post('send-batch')
@ApiOperation({ summary: '批量发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto,
@@ -76,6 +83,7 @@ export class PushNotificationsController {
@Post('send-silent')
@ApiOperation({ summary: '发送静默推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendSilentNotification(
@Body() body: { userId: string; payload: any },

View File

@@ -12,11 +12,13 @@ import { PushMessage } from './models/push-message.model';
import { PushTemplate } from './models/push-template.model';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from '../database/database.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
ConfigModule,
DatabaseModule,
UsersModule,
SequelizeModule.forFeature([
UserPushToken,
PushMessage,

View File

@@ -80,7 +80,7 @@ export class PushNotificationsService {
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
@@ -239,7 +239,7 @@ export class PushNotificationsService {
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
@@ -290,11 +290,12 @@ export class PushNotificationsService {
const message = await this.pushMessageService.createMessage(messageData);
// 查找对应的APNs结果
const apnsResult = apnsResults.sent.find(s => s.device === deviceToken) ||
const apnsResult = apnsResults.sent.includes(deviceToken) ?
{ device: deviceToken, success: true } :
apnsResults.failed.find(f => f.device === deviceToken);
if (apnsResult) {
if ('device' in apnsResult && apnsResult.device === deviceToken) {
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
// 成功发送
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
await this.pushTokenService.updateLastUsedTime(deviceToken);
@@ -424,9 +425,9 @@ export class PushNotificationsService {
/**
* 注册设备令牌
*/
async registerToken(userId: string, tokenData: any): Promise<any> {
async registerToken(tokenData: any, userId?: string,): Promise<any> {
try {
const token = await this.pushTokenService.registerToken(userId, tokenData);
const token = await this.pushTokenService.registerToken(tokenData, userId);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌注册成功',

View File

@@ -18,7 +18,7 @@ export class PushTokenService {
/**
* 注册设备令牌
*/
async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise<UserPushToken> {
async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> {
try {
this.logger.log(`Registering push token for user ${userId}`);
@@ -45,13 +45,6 @@ export class PushTokenService {
return existingToken;
}
// 检查用户是否已有其他设备的令牌,可以选择是否停用旧令牌
const userTokens = await this.pushTokenModel.findAll({
where: {
userId,
isActive: true,
},
});
// 创建新令牌
const newToken = await this.pushTokenModel.create({