diff --git a/package-lock.json b/package-lock.json index 8492f71..3fbb1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,14 +16,17 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/sequelize": "^11.0.0", "@nestjs/swagger": "^11.1.0", + "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", "@types/uuid": "^10.0.0", + "apns2": "^12.2.0", "axios": "^1.10.0", "body-parser": "^2.2.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cos-nodejs-sdk-v5": "^2.14.7", "crypto-js": "^4.2.0", + "dayjs": "^1.11.18", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", @@ -1885,6 +1888,15 @@ "node": ">=8" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.15.1", "resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", @@ -2586,6 +2598,79 @@ "npm": ">=5.10.0" } }, + "node_modules/@parse/node-apn": { + "version": "5.2.3", + "resolved": "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz", + "integrity": "sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ==", + "license": "MIT", + "dependencies": { + "debug": "4.3.3", + "jsonwebtoken": "9.0.0", + "node-forge": "1.3.1", + "verror": "1.10.1" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@parse/node-apn/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/@parse/node-apn/node_modules/debug": { + "version": "4.3.3", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@parse/node-apn/node_modules/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/@parse/node-apn/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/@parse/node-apn/node_modules/verror": { + "version": "1.10.1", + "resolved": "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/@pkgr/core": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", @@ -4330,6 +4415,19 @@ "node": ">= 8" } }, + "node_modules/apns2": { + "version": "12.2.0", + "resolved": "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz", + "integrity": "sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA==", + "license": "MIT", + "dependencies": { + "fast-jwt": "^6.0.1", + "undici": "^7.9.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -4393,6 +4491,17 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4746,6 +4855,11 @@ "node": ">= 0.8.0" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + }, "node_modules/bodec": { "version": "0.1.0", "resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz", @@ -5680,10 +5794,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "dev": true, + "version": "1.11.18", + "resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, "node_modules/debounce-fn": { @@ -6815,6 +6928,21 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-jwt": { + "version": "6.0.2", + "resolved": "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz", + "integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -9536,6 +9664,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9589,6 +9723,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/module-details-from-path": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz", @@ -9847,6 +9990,15 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9936,6 +10088,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12901,6 +13059,15 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 432ac86..f80beef 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", "@types/uuid": "^10.0.0", + "apns2": "^12.2.0", "axios": "^1.10.0", "body-parser": "^2.2.0", "class-transformer": "^0.5.1", @@ -106,4 +107,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/src/push-notifications/apns.provider.ts b/src/push-notifications/apns.provider.ts index e64b006..dc3219f 100644 --- a/src/push-notifications/apns.provider.ts +++ b/src/push-notifications/apns.provider.ts @@ -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('APNS_KEY_ID'); const teamId = this.configService.get('APNS_TEAM_ID'); + const keyId = this.configService.get('APNS_KEY_ID'); const keyPath = this.configService.get('APNS_KEY_PATH'); const bundleId = this.configService.get('APNS_BUNDLE_ID'); const environment = this.configService.get('APNS_ENVIRONMENT', 'sandbox'); - const clientCount = this.configService.get('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('APNS_CONNECTION_RETRY_LIMIT', 3), - heartBeat: this.configService.get('APNS_HEARTBEAT', 60000), - requestTimeout: this.configService.get('APNS_REQUEST_TIMEOUT', 5000), }; } /** - * 初始化APNs连接 + * 初始化APNs客户端 */ - private async initializeProvider(): Promise { + private async initializeClient(): Promise { 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 { + async send(notification: Notification, deviceTokens: string[]): Promise { + 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 { + async sendBatch(notifications: Notification[], deviceTokens: string[]): Promise { + 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 { + async manageChannels(notification: Notification, bundleId: string, action: string): Promise { 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 { + async broadcast(notification: Notification, bundleId: string): Promise { 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): 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 { 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', }; } diff --git a/src/push-notifications/interfaces/apns-config.interface.ts b/src/push-notifications/interfaces/apns-config.interface.ts index a4dcbed..1e75f0c 100644 --- a/src/push-notifications/interfaces/apns-config.interface.ts +++ b/src/push-notifications/interfaces/apns-config.interface.ts @@ -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; + title?: string; + body?: string; + alert?: any; } \ No newline at end of file diff --git a/src/push-notifications/push-notifications.controller.ts b/src/push-notifications/push-notifications.controller.ts index d45eddf..1c51166 100644 --- a/src/push-notifications/push-notifications.controller.ts +++ b/src/push-notifications/push-notifications.controller.ts @@ -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 { - 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 }, diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts index a3194f2..ee45cfd 100644 --- a/src/push-notifications/push-notifications.module.ts +++ b/src/push-notifications/push-notifications.module.ts @@ -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, diff --git a/src/push-notifications/push-notifications.service.ts b/src/push-notifications/push-notifications.service.ts index f9f2b5a..fa0d8d8 100644 --- a/src/push-notifications/push-notifications.service.ts +++ b/src/push-notifications/push-notifications.service.ts @@ -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 { + async registerToken(tokenData: any, userId?: string,): Promise { try { - const token = await this.pushTokenService.registerToken(userId, tokenData); + const token = await this.pushTokenService.registerToken(tokenData, userId); return { code: ResponseCode.SUCCESS, message: '设备令牌注册成功', diff --git a/src/push-notifications/push-token.service.ts b/src/push-notifications/push-token.service.ts index 0bcd969..fa6ae30 100644 --- a/src/push-notifications/push-token.service.ts +++ b/src/push-notifications/push-token.service.ts @@ -18,7 +18,7 @@ export class PushTokenService { /** * 注册设备令牌 */ - async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise { + async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise { 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({ diff --git a/yarn.lock b/yarn.lock index e2dd542..8f9ff13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -872,6 +872,11 @@ resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== +"@lukeed/ms@^2.0.2": + version "2.0.2" + resolved "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz" + integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA== + "@microsoft/tsdoc@0.15.1": version "0.15.1" resolved "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz" @@ -1121,6 +1126,16 @@ dependencies: consola "^3.2.3" +"@parse/node-apn@^5.0.0": + version "5.2.3" + resolved "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz" + integrity sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ== + dependencies: + debug "4.3.3" + jsonwebtoken "9.0.0" + node-forge "1.3.1" + verror "1.10.1" + "@pkgr/core@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz" @@ -2156,6 +2171,14 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +apns2@^12.2.0: + version "12.2.0" + resolved "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz" + integrity sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA== + dependencies: + fast-jwt "^6.0.1" + undici "^7.9.0" + append-field@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz" @@ -2193,6 +2216,16 @@ asap@^2.0.0: resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^5.4.1: + version "5.4.1" + resolved "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + asn1@~0.2.3: version "0.2.6" resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz" @@ -2389,6 +2422,11 @@ blessed@0.1.81: resolved "https://mirrors.tencent.com/npm/blessed/-/blessed-0.1.81.tgz" integrity sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ== +bn.js@^4.0.0: + version "4.12.2" + resolved "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz" + integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== + bodec@^0.1.0: version "0.1.0" resolved "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz" @@ -2952,16 +2990,11 @@ data-uri-to-buffer@^6.0.2: resolved "https://mirrors.tencent.com/npm/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz" integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== -dayjs@^1.11.18: +dayjs@^1.11.18, dayjs@~1.11.13: version "1.11.18" - resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz#835fa712aac52ab9dec8b1494098774ed7070a11" + resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz" integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA== -dayjs@~1.11.13: - version "1.11.13" - resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== - dayjs@~1.8.24: version "1.8.36" resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.8.36.tgz" @@ -2981,6 +3014,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" +debug@4.3.3: + version "4.3.3" + resolved "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + debug@4.3.6: version "4.3.6" resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" @@ -3139,7 +3179,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://mirrors.tencent.com/npm/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -3572,6 +3612,16 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-jwt@^6.0.1: + version "6.0.2" + resolved "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz" + integrity sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA== + dependencies: + "@lukeed/ms" "^2.0.2" + asn1.js "^5.4.1" + ecdsa-sig-formatter "^1.0.11" + mnemonist "^0.40.0" + fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" @@ -4217,7 +4267,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4909,6 +4959,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@9.0.0: + version "9.0.0" + resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz" + integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== + dependencies: + jws "^3.2.2" + lodash "^4.17.21" + ms "^2.1.1" + semver "^7.3.8" + jsonwebtoken@9.0.2, jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" @@ -5295,6 +5355,11 @@ mimic-response@^4.0.0: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^10.0.0: version "10.0.1" resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz" @@ -5345,6 +5410,13 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.6" +mnemonist@^0.40.0: + version "0.40.3" + resolved "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz" + integrity sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ== + dependencies: + obliterator "^2.0.4" + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz" @@ -5477,6 +5549,11 @@ node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-forge@1.3.1: + version "1.3.1" + resolved "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -5524,6 +5601,11 @@ object-inspect@^1.13.3: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== +obliterator@^2.0.4: + version "2.0.5" + resolved "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz" + integrity sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw== + on-finished@2.4.1, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" @@ -7113,6 +7195,11 @@ undici-types@~6.20.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici@^7.9.0: + version "7.16.0" + resolved "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz" + integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" @@ -7196,6 +7283,15 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +verror@1.10.1: + version "1.10.1" + resolved "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz" + integrity sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg== + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + vizion@~2.2.1: version "2.2.1" resolved "https://mirrors.tencent.com/npm/vizion/-/vizion-2.2.1.tgz"