- 新增基于设备令牌的推送通知接口 - 添加推送测试服务,支持应用启动时自动测试 - 新增推送测试文档说明 - 更新 APNS 配置和日志记录 - 迁移至 apns2 库的 PushType 枚举 - 替换订阅密钥文件 - 添加项目规则文档
384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2';
|
|
import * as fs from 'fs';
|
|
import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface';
|
|
|
|
interface SendResult {
|
|
sent: string[];
|
|
failed: Array<{
|
|
device: string;
|
|
error?: Error;
|
|
status?: string;
|
|
response?: any;
|
|
}>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
|
private readonly logger = new Logger(ApnsProvider.name);
|
|
private client: ApnsClient;
|
|
private config: ApnsConfig;
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
this.config = this.buildConfig();
|
|
}
|
|
|
|
async onModuleInit() {
|
|
try {
|
|
await this.initializeClient();
|
|
this.setupErrorHandlers();
|
|
this.logger.log('APNs Provider initialized successfully');
|
|
} catch (error) {
|
|
this.logger.error('Failed to initialize APNs Provider', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
try {
|
|
await this.shutdown();
|
|
this.logger.log('APNs Provider shutdown successfully');
|
|
} catch (error) {
|
|
this.logger.error('Error during APNs Provider shutdown', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 构建APNs配置
|
|
*/
|
|
private buildConfig(): ApnsConfig {
|
|
const teamId = this.configService.get<string>('APNS_TEAM_ID');
|
|
const keyId = this.configService.get<string>('APNS_KEY_ID');
|
|
const keyPath = this.configService.get<string>('APNS_KEY_PATH');
|
|
const bundleId = this.configService.get<string>('APNS_BUNDLE_ID');
|
|
const environment = this.configService.get<string>('APNS_ENVIRONMENT', 'sandbox');
|
|
|
|
if (!teamId || !keyId || !keyPath || !bundleId) {
|
|
throw new Error('Missing required APNs configuration');
|
|
}
|
|
|
|
let signingKey: string | Buffer;
|
|
try {
|
|
// 尝试读取密钥文件
|
|
if (fs.existsSync(keyPath)) {
|
|
signingKey = fs.readFileSync(keyPath);
|
|
} else {
|
|
// 如果是直接的内容而不是文件路径
|
|
signingKey = keyPath;
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`Failed to read APNs key file: ${keyPath}`, error);
|
|
throw new Error(`Invalid APNs key file: ${keyPath}`);
|
|
}
|
|
|
|
return {
|
|
team: teamId,
|
|
keyId,
|
|
signingKey,
|
|
defaultTopic: bundleId,
|
|
// production: environment === 'production',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 初始化APNs客户端
|
|
*/
|
|
private async initializeClient(): Promise<void> {
|
|
try {
|
|
this.logger.log(`Initializing APNs Client config: ${JSON.stringify(this.config)}`);
|
|
this.client = new ApnsClient(this.config);
|
|
this.logger.log(`APNs Client initialized for ${this.config.production ? 'Production' : 'Sandbox'} environment`);
|
|
} catch (error) {
|
|
this.logger.error('Failed to initialize APNs Client', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 设置错误处理器
|
|
*/
|
|
private setupErrorHandlers(): void {
|
|
// 监听特定错误
|
|
this.client.on(Errors.badDeviceToken, (err) => {
|
|
this.logger.error(`Bad device token: ${err}`, err.reason);
|
|
});
|
|
|
|
this.client.on(Errors.unregistered, (err) => {
|
|
this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason);
|
|
});
|
|
|
|
this.client.on(Errors.topicDisallowed, (err) => {
|
|
this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason);
|
|
});
|
|
|
|
// 监听所有错误
|
|
this.client.on(Errors.error, (err) => {
|
|
this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 发送单个通知
|
|
*/
|
|
async send(notification: Notification, deviceTokens: string[]): Promise<SendResult> {
|
|
const results: SendResult = {
|
|
sent: [],
|
|
failed: []
|
|
};
|
|
|
|
try {
|
|
|
|
for (const deviceToken of deviceTokens) {
|
|
try {
|
|
// 为每个设备令牌创建新的通知实例
|
|
const deviceNotification = this.createDeviceNotification(notification, deviceToken);
|
|
|
|
this.logger.log(`Sending notification to device this.client.send deviceNotification ${JSON.stringify(deviceNotification, null, 2)}`);
|
|
|
|
await this.client.send(deviceNotification);
|
|
results.sent.push(deviceToken);
|
|
} catch (error) {
|
|
results.failed.push({
|
|
device: deviceToken,
|
|
error: error as Error
|
|
});
|
|
}
|
|
}
|
|
|
|
this.logResults(results);
|
|
return results;
|
|
} catch (error) {
|
|
this.logger.error('Error sending notification', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 批量发送通知
|
|
*/
|
|
async sendBatch(notifications: Notification[], deviceTokens: string[]): Promise<SendResult> {
|
|
const results: SendResult = {
|
|
sent: [],
|
|
failed: []
|
|
};
|
|
|
|
try {
|
|
this.logger.debug(`Sending ${notifications.length} notifications to ${deviceTokens.length} devices`);
|
|
|
|
const deviceNotifications: Notification[] = [];
|
|
for (const notification of notifications) {
|
|
for (const deviceToken of deviceTokens) {
|
|
deviceNotifications.push(this.createDeviceNotification(notification, deviceToken));
|
|
}
|
|
}
|
|
|
|
const sendResults = await this.client.sendMany(deviceNotifications);
|
|
|
|
// 处理 sendMany 的结果
|
|
sendResults.forEach((result, index) => {
|
|
const deviceIndex = index % deviceTokens.length;
|
|
const deviceToken = deviceTokens[deviceIndex];
|
|
|
|
if (result && typeof result === 'object' && 'error' in result) {
|
|
results.failed.push({
|
|
device: deviceToken,
|
|
error: (result as any).error
|
|
});
|
|
} else {
|
|
results.sent.push(deviceToken);
|
|
}
|
|
});
|
|
|
|
this.logResults(results);
|
|
return results;
|
|
} catch (error) {
|
|
this.logger.error('Error sending batch notifications', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 管理推送通道
|
|
*/
|
|
async manageChannels(notification: Notification, bundleId: string, action: string): Promise<any> {
|
|
try {
|
|
this.logger.debug(`Managing channels for bundle ${bundleId} with action ${action}`);
|
|
|
|
// apns2 库没有直接的 manageChannels 方法,这里需要实现自定义逻辑
|
|
// 或者使用原始的 HTTP 请求来管理通道
|
|
this.logger.warn(`Channel management not directly supported in apns2 library. Action: ${action}`);
|
|
|
|
return { message: 'Channel management not implemented in apns2 library' };
|
|
} catch (error) {
|
|
this.logger.error('Error managing channels', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 广播实时活动通知
|
|
*/
|
|
async broadcast(notification: Notification, bundleId: string): Promise<any> {
|
|
try {
|
|
this.logger.debug(`Broadcasting to bundle ${bundleId}`);
|
|
|
|
// apns2 库没有直接的 broadcast 方法,这里需要实现自定义逻辑
|
|
// 或者使用原始的 HTTP 请求来广播
|
|
this.logger.warn(`Broadcast not directly supported in apns2 library. Bundle: ${bundleId}`);
|
|
|
|
return { message: 'Broadcast not implemented in apns2 library' };
|
|
} catch (error) {
|
|
this.logger.error('Error broadcasting', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 创建标准通知
|
|
*/
|
|
createNotification(options: ApnsNotificationOptions): Notification {
|
|
// 构建通知选项
|
|
const notificationOptions: any = {};
|
|
|
|
// 设置 APS 属性
|
|
const aps: any = {};
|
|
|
|
if (options.badge !== undefined) {
|
|
notificationOptions.badge = options.badge;
|
|
}
|
|
|
|
if (options.sound) {
|
|
notificationOptions.sound = options.sound;
|
|
}
|
|
|
|
if (options.contentAvailable) {
|
|
notificationOptions.contentAvailable = true;
|
|
}
|
|
|
|
if (options.mutableContent) {
|
|
notificationOptions.mutableContent = true;
|
|
}
|
|
|
|
if (options.priority) {
|
|
notificationOptions.priority = options.priority;
|
|
}
|
|
|
|
if (options.pushType) {
|
|
notificationOptions.type = options.pushType;
|
|
}
|
|
|
|
// 添加自定义数据
|
|
if (options.data) {
|
|
notificationOptions.data = options.data;
|
|
}
|
|
|
|
// 创建通知对象,但不指定设备令牌(将在发送时设置)
|
|
return new Notification('', notificationOptions);
|
|
}
|
|
|
|
/**
|
|
* 创建基本通知
|
|
*/
|
|
createBasicNotification(deviceToken: string, title: string, body?: string, options?: Partial<ApnsNotificationOptions>): Notification {
|
|
// 构建通知选项
|
|
const notificationOptions: any = {
|
|
alert: {
|
|
title,
|
|
body: body || ''
|
|
}
|
|
};
|
|
|
|
if (options?.badge !== undefined) {
|
|
notificationOptions.badge = options.badge;
|
|
}
|
|
|
|
if (options?.sound) {
|
|
notificationOptions.sound = options.sound;
|
|
}
|
|
|
|
if (options?.contentAvailable) {
|
|
notificationOptions.contentAvailable = true;
|
|
}
|
|
|
|
if (options?.mutableContent) {
|
|
notificationOptions.mutableContent = true;
|
|
}
|
|
|
|
if (options?.priority) {
|
|
notificationOptions.priority = options.priority;
|
|
}
|
|
|
|
if (options?.pushType) {
|
|
notificationOptions.type = options.pushType;
|
|
}
|
|
|
|
// 添加自定义数据
|
|
if (options?.data) {
|
|
notificationOptions.data = options.data;
|
|
}
|
|
|
|
return new Notification(deviceToken, notificationOptions);
|
|
}
|
|
|
|
/**
|
|
* 创建静默通知
|
|
*/
|
|
createSilentNotification(deviceToken: string): SilentNotification {
|
|
return new SilentNotification(deviceToken);
|
|
}
|
|
|
|
/**
|
|
* 为特定设备创建通知实例
|
|
*/
|
|
private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
|
|
// 创建新的通知实例,使用相同的选项但不同的设备令牌
|
|
return new Notification(deviceToken, {
|
|
alert: notification.options.alert,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 记录推送结果
|
|
*/
|
|
private logResults(results: SendResult): void {
|
|
const { sent, failed } = results;
|
|
|
|
this.logger.log(`Push results: ${sent.length} sent, ${failed.length} failed`);
|
|
|
|
if (failed.length > 0) {
|
|
failed.forEach((failure) => {
|
|
if (failure.error) {
|
|
this.logger.error(`Push error for device ${failure.device}: ${failure.error.message}`);
|
|
} else {
|
|
this.logger.warn(`Push rejected for device ${failure.device}: ${failure.status} - ${JSON.stringify(failure.response)}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 关闭连接
|
|
*/
|
|
async shutdown(): Promise<void> {
|
|
try {
|
|
if (this.client) {
|
|
await this.client.close();
|
|
}
|
|
|
|
this.logger.log('APNs Provider connections closed');
|
|
} catch (error) {
|
|
this.logger.error('Error closing APNs Provider connections', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取Provider状态
|
|
*/
|
|
getStatus(): { connected: boolean; environment: string } {
|
|
return {
|
|
connected: !!this.client,
|
|
environment: this.config.production ? 'production' : 'sandbox',
|
|
};
|
|
}
|
|
} |