Merge branch 'feature/push'

This commit is contained in:
richarjiang
2025-10-16 10:04:09 +08:00
44 changed files with 6319 additions and 12 deletions

View File

@@ -19,6 +19,7 @@ import { DietRecordsModule } from './diet-records/diet-records.module';
import { FoodLibraryModule } from './food-library/food-library.module';
import { WaterRecordsModule } from './water-records/water-records.module';
import { ChallengesModule } from './challenges/challenges.module';
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
@Module({
imports: [
@@ -43,6 +44,7 @@ import { ChallengesModule } from './challenges/challenges.module';
FoodLibraryModule,
WaterRecordsModule,
ChallengesModule,
PushNotificationsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -15,7 +15,10 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@@ -27,6 +30,7 @@ export class DietRecordsController {
constructor(
private readonly dietRecordsService: DietRecordsService,
private readonly nutritionAnalysisService: NutritionAnalysisService,
) { }
/**
@@ -161,4 +165,57 @@ export class DietRecordsController {
requestDto.mealType
);
}
/**
* 分析食物营养成分表图片
*/
@UseGuards(JwtAuthGuard)
@Post('analyze-nutrition-image')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '分析食物营养成分表图片' })
@ApiBody({ type: NutritionAnalysisRequestDto })
@ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto })
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 401, description: '未授权访问' })
@ApiResponse({ status: 500, description: '服务器内部错误' })
async analyzeNutritionImage(
@Body() requestDto: NutritionAnalysisRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<NutritionAnalysisResponseDto> {
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
if (!requestDto.imageUrl) {
return {
success: false,
data: [],
message: '请提供图片URL'
};
}
// 验证URL格式
try {
new URL(requestDto.imageUrl);
} catch (error) {
return {
success: false,
data: [],
message: '图片URL格式不正确'
};
}
try {
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
return result;
} catch (error) {
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
}

View File

@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { DietRecordsController } from './diet-records.controller';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { UserDietHistory } from '../users/models/user-diet-history.model';
import { ActivityLog } from '../activity-logs/models/activity-log.model';
import { UsersModule } from '../users/users.module';
@@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
forwardRef(() => AiCoachModule),
],
controllers: [DietRecordsController],
providers: [DietRecordsService],
exports: [DietRecordsService],
providers: [DietRecordsService, NutritionAnalysisService],
exports: [DietRecordsService, NutritionAnalysisService],
})
export class DietRecordsModule { }

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析请求DTO
*/
export class NutritionAnalysisRequestDto {
@ApiProperty({
description: '营养成分表图片URL',
example: 'https://example.com/nutrition-label.jpg',
required: true
})
imageUrl: string;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析结果项
*/
export class NutritionAnalysisItemDto {
@ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' })
key: string;
@ApiProperty({ description: '营养素的中文名称', example: '热量' })
name: string;
@ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' })
value: string;
@ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。' })
analysis: string;
}
/**
* 营养成分分析响应DTO
*/
export class NutritionAnalysisResponseDto {
@ApiProperty({ description: '操作是否成功', example: true })
success: boolean;
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
data: NutritionAnalysisItemDto[];
@ApiProperty({ description: '响应消息', required: false })
message?: string;
}

View File

@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
/**
* 营养成分分析结果接口
*/
export interface NutritionAnalysisResult {
key: string; // 营养素的唯一标识,如 energy_kcal
name: string; // 营养素的中文名称,如"热量"
value: string; // 从图片中识别的原始值和单位,如"840千焦"
analysis: string; // 针对该营养素的详细健康建议
}
/**
* 营养成分分析响应接口
*/
export interface NutritionAnalysisResponse {
success: boolean;
data: NutritionAnalysisResult[];
message?: string;
}
/**
* 营养成分分析服务
* 负责处理食物营养成分表的AI分析
*
* 支持多种AI模型
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
*/
@Injectable()
export class NutritionAnalysisService {
private readonly logger = new Logger(NutritionAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly apiProvider: string;
constructor(private readonly configService: ConfigService) {
// Support both GLM-4.5V and DashScope (Qwen) models
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
if (this.apiProvider === 'glm') {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
} else {
// DashScope Configuration (default)
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
this.client = new OpenAI({
apiKey: dashScopeApiKey,
baseURL,
});
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
}
/**
* 分析食物营养成分表图片
* @param imageUrl 图片URL
* @returns 营养成分分析结果
*/
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
const prompt = this.buildNutritionAnalysisPrompt();
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
const rawResult = completion.choices?.[0]?.message?.content || '[]';
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
return this.parseNutritionAnalysisResult(rawResult);
} catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
/**
* 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope
* @param prompt 提示文本
* @param imageUrls 图片URL数组
* @returns API响应
*/
private async makeVisionApiCall(prompt: string, imageUrls: string[]) {
const baseParams = {
model: this.visionModel,
temperature: 0.3,
response_format: { type: 'json_object' } as any,
};
// 处理图片URL
const processedImages = imageUrls.map((imageUrl) => ({
type: 'image_url',
image_url: { url: imageUrl } as any,
}));
if (this.apiProvider === 'glm') {
// GLM-4.5V format
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
} as any);
} else {
// DashScope format (default)
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
});
}
}
/**
* 构建营养成分分析提示
* @returns 提示文本
*/
private buildNutritionAnalysisPrompt(): string {
return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。
**任务要求:**
1. 识别图片中的营养成分表,提取所有可见的营养素信息
2. 为每个营养素提供详细的健康建议和分析
3. 返回严格的JSON数组格式不包含任何额外的解释或对话文本
**输出格式要求:**
请严格按照以下JSON数组格式返回每个对象包含四个字段
[
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
}
]
**营养素标识符对照表:**
- 热量/能量: energy_kcal
- 蛋白质: protein
- 脂肪: fat
- 碳水化合物: carbohydrate
- 膳食纤维: fiber
- 钠: sodium
- 钙: calcium
- 铁: iron
- 锌: zinc
- 维生素C: vitamin_c
- 维生素A: vitamin_a
- 维生素D: vitamin_d
- 维生素E: vitamin_e
- 维生素B1: vitamin_b1
- 维生素B2: vitamin_b2
- 维生素B6: vitamin_b6
- 维生素B12: vitamin_b12
- 叶酸: folic_acid
- 胆固醇: cholesterol
- 饱和脂肪: saturated_fat
- 反式脂肪: trans_fat
- 糖: sugar
- 其他营养素: other_nutrient
**分析要求:**
1. 如果图片中没有营养成分表,返回空数组 []
2. 为每个识别到的营养素提供具体的健康建议
3. 建议应包含营养素的作用、摄入量参考和健康影响
4. 数值分析要准确,建议要专业且实用
5. 只返回JSON数组不要包含任何其他文本
**重要提醒:**
- 严格按照JSON数组格式返回
- 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析`;
}
/**
* 解析营养成分分析结果
* @param rawResult 原始结果字符串
* @returns 解析后的分析结果
*/
private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse {
try {
// 尝试解析JSON
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`营养成分分析JSON解析失败: ${parseError}`);
this.logger.error(`原始结果: ${rawResult}`);
return {
success: false,
data: [],
message: '营养成分表解析失败,无法识别有效的营养信息'
};
}
// 确保结果是数组
if (!Array.isArray(parsedResult)) {
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
return {
success: false,
data: [],
message: '营养成分表格式错误,无法识别有效的营养信息'
};
}
// 验证和标准化每个营养素项
const nutritionData: NutritionAnalysisResult[] = [];
for (const item of parsedResult) {
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
nutritionData.push({
key: String(item.key).trim(),
name: String(item.name).trim(),
value: String(item.value).trim(),
analysis: String(item.analysis).trim()
});
} else {
this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`);
}
}
if (nutritionData.length === 0) {
return {
success: false,
data: [],
message: '图片中未检测到有效的营养成分表信息'
};
}
this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`);
return {
success: true,
data: nutritionData
};
} catch (error) {
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表处理失败,请稍后重试'
};
}
}
}

View File

@@ -0,0 +1,132 @@
# 推送测试功能
本文档介绍如何使用推送测试功能,该功能可以在应用程序启动时自动获取表中的已有 token 进行消息推送。
## 功能概述
推送测试功能包括以下特性:
1. **自动测试**:应用程序启动时自动执行推送测试(可通过环境变量控制)
2. **手动触发**:通过 API 接口手动触发推送测试
3. **统计信息**:获取推送令牌的统计信息
4. **可配置内容**:可以自定义测试推送的标题和内容
## 环境变量配置
`.env` 文件中添加以下配置:
```env
# 推送测试配置
# 启用/禁用应用启动时的推送测试
ENABLE_PUSH_TEST=false
# 测试推送消息内容(可选,如果不提供将使用默认值)
PUSH_TEST_TITLE=测试推送
PUSH_TEST_BODY=这是一条测试推送消息,用于验证推送功能是否正常工作。
```
### 环境变量说明
- `ENABLE_PUSH_TEST`: 设置为 `true` 启用应用启动时的推送测试,设置为 `false` 禁用(默认为 `false`
- `PUSH_TEST_TITLE`: 测试推送的标题(可选)
- `PUSH_TEST_BODY`: 测试推送的内容(可选)
## API 接口
### 1. 手动触发推送测试
**请求方式**: `POST`
**请求路径**: `/api/push-test/trigger`
**响应示例**:
```json
{
"code": 0,
"message": "Push test completed",
"data": {
"success": true,
"message": "Push test completed"
}
}
```
### 2. 获取推送令牌统计信息
**请求方式**: `GET`
**请求路径**: `/api/push-test/stats`
**响应示例**:
```json
{
"code": 0,
"message": "获取推送令牌统计信息成功",
"data": {
"totalTokens": 100,
"activeTokens": 85,
"inactiveTokens": 15,
"recentlyActiveTokens": 60
}
}
```
## 工作原理
1. **自动测试流程**:
- 应用启动时,`PushTestService` 会检查 `ENABLE_PUSH_TEST` 环境变量
- 如果启用,服务会在应用完全启动后 5 秒执行推送测试
- 测试会获取最多 10 个活跃的推送令牌
- 向这些令牌发送测试推送消息
2. **手动测试流程**:
- 通过调用 `/api/push-test/trigger` 接口手动触发测试
- 测试流程与自动测试相同
3. **统计信息**:
- `totalTokens`: 总令牌数
- `activeTokens`: 活跃令牌数
- `inactiveTokens`: 非活跃令牌数
- `recentlyActiveTokens`: 最近 7 天活跃的令牌数
## 注意事项
1. **生产环境使用**:
- 在生产环境中使用前,请确保测试推送内容不会对用户造成困扰
- 建议在非高峰时段进行测试
2. **令牌限制**:
- 自动测试每次最多获取 10 个令牌,避免发送过多推送
- 只会向标记为 `isActive=true` 的令牌发送推送
3. **错误处理**:
- 如果推送失败,服务会记录详细的错误日志
- 无效的令牌会被自动标记为非活跃状态
## 日志记录
推送测试功能会记录以下日志:
- 测试开始和结束
- 发送成功和失败的统计
- 每个令牌的推送结果
- 错误详情
这些日志可以帮助排查推送问题。
## 示例用法
### 启用自动测试
1.`.env` 文件中设置 `ENABLE_PUSH_TEST=true`
2. 重启应用程序
3. 查看日志确认测试是否成功执行
### 手动触发测试
```bash
curl -X POST http://localhost:3002/api/push-test/trigger
```
### 查看统计信息
```bash
curl -X GET http://localhost:3002/api/push-test/stats

View File

@@ -0,0 +1,384 @@
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',
};
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { PushType } from 'apns2';
export class CreatePushTemplateDto {
@ApiProperty({ description: '模板键' })
@IsString()
@IsNotEmpty()
templateKey: string;
@ApiProperty({ description: '模板标题' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: '模板内容' })
@IsString()
@IsNotEmpty()
body: string;
@ApiProperty({ description: '负载模板', required: false })
@IsObject()
@IsOptional()
payloadTemplate?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty } from '@nestjs/swagger';
import { ResponseCode } from '../../base.dto';
export class DevicePushResult {
@ApiProperty({ description: '设备令牌' })
deviceToken: string;
@ApiProperty({ description: '用户ID可选如果可获取' })
userId?: string;
@ApiProperty({ description: '是否成功' })
success: boolean;
@ApiProperty({ description: '错误信息', required: false })
error?: string;
@ApiProperty({ description: 'APNs响应', required: false })
apnsResponse?: any;
}
export class DevicePushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '推送结果' })
data: {
success: boolean;
sentCount: number;
failedCount: number;
results: DevicePushResult[];
};
}
export class BatchDevicePushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '批量推送结果' })
data: {
totalTokens: number;
successCount: number;
failedCount: number;
results: DevicePushResult[];
};
}

View File

@@ -0,0 +1,93 @@
import { ApiProperty } from '@nestjs/swagger';
import { ResponseCode } from '../../base.dto';
export class PushResult {
@ApiProperty({ description: '用户ID' })
userId: string;
@ApiProperty({ description: '设备令牌' })
deviceToken: string;
@ApiProperty({ description: '是否成功' })
success: boolean;
@ApiProperty({ description: '错误信息', required: false })
error?: string;
@ApiProperty({ description: 'APNs响应', required: false })
apnsResponse?: any;
}
export class PushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '推送结果' })
data: {
success: boolean;
sentCount: number;
failedCount: number;
results: PushResult[];
};
}
export class BatchPushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '批量推送结果' })
data: {
totalUsers: number;
totalTokens: number;
successCount: number;
failedCount: number;
results: PushResult[];
};
}
export class RegisterTokenResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '注册结果' })
data: {
success: boolean;
tokenId: string;
};
}
export class UpdateTokenResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '更新结果' })
data: {
success: boolean;
tokenId: string;
};
}
export class UnregisterTokenResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '注销结果' })
data: {
success: boolean;
};
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
import { DeviceType } from '../enums/device-type.enum';
export class RegisterDeviceTokenDto {
@ApiProperty({ description: '设备推送令牌' })
@IsString()
@IsNotEmpty()
deviceToken: string;
@ApiProperty({ description: '设备类型', enum: DeviceType })
@IsEnum(DeviceType)
deviceType: DeviceType;
@ApiProperty({ description: '应用版本', required: false })
@IsString()
@IsOptional()
appVersion?: string;
@ApiProperty({ description: '操作系统版本', required: false })
@IsString()
@IsOptional()
osVersion?: string;
@ApiProperty({ description: '设备名称', required: false })
@IsString()
@IsOptional()
deviceName?: string;
}

View File

@@ -0,0 +1,38 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional } from 'class-validator';
export class SendPushByTemplateDto {
@ApiProperty({ description: '用户ID列表' })
@IsArray()
@IsString({ each: true })
userIds: string[];
@ApiProperty({ description: '模板键' })
@IsString()
@IsNotEmpty()
templateKey: string;
@ApiProperty({ description: '模板数据' })
@IsObject()
@IsNotEmpty()
data: any;
@ApiProperty({ description: '自定义数据', required: false })
@IsObject()
@IsOptional()
payload?: any;
@ApiProperty({ description: '折叠ID', required: false })
@IsString()
@IsOptional()
collapseId?: string;
@ApiProperty({ description: '声音', required: false })
@IsString()
@IsOptional()
sound?: string;
@ApiProperty({ description: '徽章数', required: false })
@IsOptional()
badge?: number;
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { PushType } from 'apns2';
export class SendPushNotificationDto {
@ApiProperty({ description: '用户ID列表' })
@IsArray()
@IsString({ each: true })
userIds: string[];
@ApiProperty({ description: '推送标题' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: '推送内容' })
@IsString()
@IsNotEmpty()
body: string;
@ApiProperty({ description: '自定义数据', required: false })
@IsObject()
@IsOptional()
payload?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
@ApiProperty({ description: '过期时间(秒)', required: false })
@IsNumber()
@IsOptional()
expiry?: number;
@ApiProperty({ description: '折叠ID', required: false })
@IsString()
@IsOptional()
collapseId?: string;
@ApiProperty({ description: '声音', required: false })
@IsString()
@IsOptional()
sound?: string;
@ApiProperty({ description: '徽章数', required: false })
@IsNumber()
@IsOptional()
badge?: number;
@ApiProperty({ description: '是否可变内容', required: false })
@IsOptional()
mutableContent?: boolean;
@ApiProperty({ description: '是否静默推送', required: false })
@IsOptional()
contentAvailable?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, IsNotEmpty, IsObject, IsOptional, IsEnum, IsNumber } from 'class-validator';
import { PushType } from 'apns2';
export class SendPushToDevicesDto {
@ApiProperty({ description: '设备令牌列表' })
@IsArray()
@IsString({ each: true })
deviceTokens: string[];
@ApiProperty({ description: '推送标题' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: '推送内容' })
@IsString()
@IsNotEmpty()
body: string;
@ApiProperty({ description: '自定义数据', required: false })
@IsObject()
@IsOptional()
payload?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
@ApiProperty({ description: '过期时间(秒)', required: false })
@IsNumber()
@IsOptional()
expiry?: number;
@ApiProperty({ description: '折叠ID', required: false })
@IsString()
@IsOptional()
collapseId?: string;
@ApiProperty({ description: '声音', required: false })
@IsString()
@IsOptional()
sound?: string;
@ApiProperty({ description: '徽章数', required: false })
@IsNumber()
@IsOptional()
badge?: number;
@ApiProperty({ description: '是否可变内容', required: false })
@IsOptional()
mutableContent?: boolean;
@ApiProperty({ description: '是否静默推送', required: false })
@IsOptional()
contentAvailable?: boolean;
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class UpdateDeviceTokenDto {
@ApiProperty({ description: '当前设备推送令牌' })
@IsString()
@IsNotEmpty()
currentDeviceToken: string;
@ApiProperty({ description: '新的设备推送令牌' })
@IsString()
@IsNotEmpty()
newDeviceToken: string;
@ApiProperty({ description: '应用版本', required: false })
@IsString()
@IsOptional()
appVersion?: string;
@ApiProperty({ description: '操作系统版本', required: false })
@IsString()
@IsOptional()
osVersion?: string;
@ApiProperty({ description: '设备名称', required: false })
@IsString()
@IsOptional()
deviceName?: string;
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { PushType } from 'apns2';
import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsBoolean } from 'class-validator';
export class UpdatePushTemplateDto {
@ApiProperty({ description: '模板标题', required: false })
@IsString()
@IsOptional()
title?: string;
@ApiProperty({ description: '模板内容', required: false })
@IsString()
@IsOptional()
body?: string;
@ApiProperty({ description: '负载模板', required: false })
@IsObject()
@IsOptional()
payloadTemplate?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
@ApiProperty({ description: '是否激活', required: false })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,4 @@
export enum DeviceType {
IOS = 'IOS',
ANDROID = 'ANDROID',
}

View File

@@ -0,0 +1,6 @@
export enum PushMessageStatus {
PENDING = 'PENDING',
SENT = 'SENT',
FAILED = 'FAILED',
EXPIRED = 'EXPIRED',
}

View File

@@ -0,0 +1,26 @@
export interface ApnsConfig {
team: string;
keyId: string;
signingKey: string | Buffer;
defaultTopic: string;
host?: string;
port?: number;
production?: boolean;
}
export interface ApnsNotificationOptions {
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

@@ -0,0 +1,65 @@
import { PushType } from 'apns2';
export interface PushNotificationRequest {
userIds: string[];
title: string;
body: string;
payload?: any;
pushType?: PushType;
priority?: number;
expiry?: number;
collapseId?: string;
}
export interface PushNotificationByTemplateRequest {
userIds: string[];
templateKey: string;
data: any;
payload?: any;
}
export interface PushResult {
userId: string;
deviceToken: string;
success: boolean;
error?: string;
apnsResponse?: any;
}
export interface BatchPushResult {
totalUsers: number;
totalTokens: number;
successCount: number;
failedCount: number;
results: PushResult[];
}
export interface RenderedTemplate {
title: string;
body: string;
payload?: any;
pushType: PushType;
priority: number;
}
export interface PushStats {
totalSent: number;
totalFailed: number;
successRate: number;
averageDeliveryTime: number;
errorBreakdown: Record<string, number>;
}
export interface QueryOptions {
limit?: number;
offset?: number;
startDate?: Date;
endDate?: Date;
status?: string;
messageType?: string;
}
export interface TimeRange {
startDate: Date;
endDate: Date;
}

View File

@@ -0,0 +1,147 @@
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript';
import { PushMessageStatus } from '../enums/push-message-status.enum';
import { PushType } from 'apns2';
@Table({
tableName: 't_push_messages',
underscored: true,
indexes: [
{
name: 'idx_user_id',
fields: ['user_id'],
},
{
name: 'idx_status',
fields: ['status'],
},
{
name: 'idx_created_at',
fields: ['created_at'],
},
{
name: 'idx_message_type',
fields: ['message_type'],
},
],
})
export class PushMessage extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '设备推送令牌',
})
declare deviceToken: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '消息类型',
})
declare messageType: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '推送标题',
})
declare title?: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '推送内容',
})
declare body?: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '自定义负载数据',
})
declare payload?: any;
@Column({
type: DataType.ENUM(...Object.values(PushType)),
allowNull: false,
defaultValue: PushType.alert,
comment: '推送类型',
})
declare pushType: PushType;
@Column({
type: DataType.TINYINT,
allowNull: false,
defaultValue: 10,
comment: '优先级',
})
declare priority: number;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '过期时间',
})
declare expiry?: Date;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '折叠ID',
})
declare collapseId?: string;
@Column({
type: DataType.ENUM(...Object.values(PushMessageStatus)),
allowNull: false,
defaultValue: PushMessageStatus.PENDING,
comment: '推送状态',
})
declare status: PushMessageStatus;
@Column({
type: DataType.JSON,
allowNull: true,
comment: 'APNs响应数据',
})
declare apnsResponse?: any;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '错误信息',
})
declare errorMessage?: string;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '发送时间',
})
declare sentAt?: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,95 @@
import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript';
import { PushType } from 'apns2';
@Table({
tableName: 't_push_templates',
underscored: true,
indexes: [
{
name: 'idx_template_key',
fields: ['template_key'],
unique: true,
},
{
name: 'idx_is_active',
fields: ['is_active'],
},
],
})
export class PushTemplate extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: false,
unique: true,
field: 'template_key',
comment: '模板键',
})
declare templateKey: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '模板标题',
})
declare title: string;
@Column({
type: DataType.TEXT,
allowNull: false,
comment: '模板内容',
})
declare body: string;
@Column({
type: DataType.JSON,
allowNull: true,
field: 'payload_template',
comment: '负载模板',
})
declare payloadTemplate?: any;
@Column({
type: DataType.ENUM(...Object.values(PushType)),
allowNull: false,
defaultValue: PushType.alert,
field: 'push_type',
comment: '推送类型',
})
declare pushType: PushType;
@Column({
type: DataType.TINYINT,
allowNull: false,
defaultValue: 10,
comment: '优先级',
})
declare priority: number;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active',
comment: '是否激活',
})
declare isActive: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,100 @@
import { Column, Model, Table, DataType, Index, Unique } from 'sequelize-typescript';
import { DeviceType } from '../enums/device-type.enum';
@Table({
tableName: 't_user_push_tokens',
underscored: true,
indexes: [
{
name: 'idx_user_id',
fields: ['user_id'],
},
{
name: 'idx_device_token',
fields: ['device_token'],
},
{
name: 'idx_user_device',
fields: ['user_id', 'device_token'],
unique: true,
},
],
})
export class UserPushToken extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '设备推送令牌',
})
declare deviceToken: string;
@Column({
type: DataType.ENUM(...Object.values(DeviceType)),
allowNull: false,
defaultValue: DeviceType.IOS,
comment: '设备类型',
})
declare deviceType: DeviceType;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '应用版本',
})
declare appVersion?: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '操作系统版本',
})
declare osVersion?: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '设备名称',
})
declare deviceName?: string;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否激活',
})
declare isActive: boolean;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '最后使用时间',
})
declare lastUsedAt?: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,387 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { PushMessage } from './models/push-message.model';
import { PushMessageStatus } from './enums/push-message-status.enum';
import { PushStats, QueryOptions, TimeRange } from './interfaces/push-notification.interface';
export interface CreatePushMessageDto {
userId: string;
deviceToken: string;
messageType: string;
title?: string;
body?: string;
payload?: any;
pushType?: string;
priority?: number;
expiry?: Date;
collapseId?: string;
}
@Injectable()
export class PushMessageService {
private readonly logger = new Logger(PushMessageService.name);
constructor(
@InjectModel(PushMessage)
private readonly messageModel: typeof PushMessage,
) { }
/**
* 创建推送消息记录
*/
async createMessage(messageData: CreatePushMessageDto): Promise<PushMessage> {
try {
this.logger.log(`Creating push message for user ${messageData.userId}`);
const message = await this.messageModel.create({
userId: messageData.userId,
deviceToken: messageData.deviceToken,
messageType: messageData.messageType,
title: messageData.title,
body: messageData.body,
payload: messageData.payload,
pushType: messageData.pushType,
priority: messageData.priority || 10,
expiry: messageData.expiry,
collapseId: messageData.collapseId,
status: PushMessageStatus.PENDING,
});
this.logger.log(`Successfully created push message with ID: ${message.id}`);
return message;
} catch (error) {
this.logger.error(`Failed to create push message: ${error.message}`, error);
throw error;
}
}
/**
* 更新消息状态
*/
async updateMessageStatus(id: string, status: PushMessageStatus, response?: any, errorMessage?: string): Promise<void> {
try {
this.logger.log(`Updating push message status to ${status} for ID: ${id}`);
const updateData: any = {
status,
};
if (status === PushMessageStatus.SENT) {
updateData.sentAt = new Date();
}
if (response) {
updateData.apnsResponse = response;
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.messageModel.update(updateData, {
where: {
id,
},
});
this.logger.log(`Successfully updated push message status for ID: ${id}`);
} catch (error) {
this.logger.error(`Failed to update push message status: ${error.message}`, error);
throw error;
}
}
/**
* 批量更新消息状态
*/
async updateMessageStatusBatch(ids: string[], status: PushMessageStatus, response?: any, errorMessage?: string): Promise<void> {
try {
this.logger.log(`Batch updating ${ids.length} push messages to status: ${status}`);
const updateData: any = {
status,
};
if (status === PushMessageStatus.SENT) {
updateData.sentAt = new Date();
}
if (response) {
updateData.apnsResponse = response;
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
await this.messageModel.update(updateData, {
where: {
id: {
[Op.in]: ids,
},
},
});
this.logger.log(`Successfully batch updated ${ids.length} push messages`);
} catch (error) {
this.logger.error(`Failed to batch update push messages: ${error.message}`, error);
throw error;
}
}
/**
* 获取消息历史
*/
async getMessageHistory(userId: string, options: QueryOptions = {}): Promise<PushMessage[]> {
try {
const whereClause: any = {
userId,
};
if (options.status) {
whereClause.status = options.status;
}
if (options.messageType) {
whereClause.messageType = options.messageType;
}
if (options.startDate || options.endDate) {
whereClause.createdAt = {};
if (options.startDate) {
whereClause.createdAt[Op.gte] = options.startDate;
}
if (options.endDate) {
whereClause.createdAt[Op.lte] = options.endDate;
}
}
const messages = await this.messageModel.findAll({
where: whereClause,
order: [['createdAt', 'DESC']],
limit: options.limit,
offset: options.offset,
});
this.logger.log(`Found ${messages.length} messages for user ${userId}`);
return messages;
} catch (error) {
this.logger.error(`Failed to get message history: ${error.message}`, error);
throw error;
}
}
/**
* 获取消息统计
*/
async getMessageStats(userId?: string, timeRange?: TimeRange): Promise<PushStats> {
try {
const whereClause: any = {};
if (userId) {
whereClause.userId = userId;
}
if (timeRange) {
whereClause.createdAt = {
[Op.between]: [timeRange.startDate, timeRange.endDate],
};
}
const totalSent = await this.messageModel.count({
where: {
...whereClause,
status: PushMessageStatus.SENT,
},
});
const totalFailed = await this.messageModel.count({
where: {
...whereClause,
status: PushMessageStatus.FAILED,
},
});
const total = totalSent + totalFailed;
const successRate = total > 0 ? (totalSent / total) * 100 : 0;
// 获取错误分布
const errorMessages = await this.messageModel.findAll({
where: {
...whereClause,
status: PushMessageStatus.FAILED,
errorMessage: {
[Op.not]: null,
},
},
attributes: ['errorMessage'],
});
const errorBreakdown: Record<string, number> = {};
errorMessages.forEach((message) => {
if (message.errorMessage) {
const errorKey = this.categorizeError(message.errorMessage);
errorBreakdown[errorKey] = (errorBreakdown[errorKey] || 0) + 1;
}
});
// 计算平均发送时间(简化版本)
const averageDeliveryTime = await this.calculateAverageDeliveryTime(whereClause);
const stats: PushStats = {
totalSent,
totalFailed,
successRate,
averageDeliveryTime,
errorBreakdown,
};
this.logger.log(`Generated message stats: ${JSON.stringify(stats)}`);
return stats;
} catch (error) {
this.logger.error(`Failed to get message stats: ${error.message}`, error);
throw error;
}
}
/**
* 清理过期消息
*/
async cleanupExpiredMessages(): Promise<number> {
try {
this.logger.log('Starting cleanup of expired messages');
// 清理30天前的消息
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.messageModel.destroy({
where: {
createdAt: {
[Op.lt]: thirtyDaysAgo,
},
},
});
this.logger.log(`Cleaned up ${result} expired messages`);
return result;
} catch (error) {
this.logger.error(`Failed to cleanup expired messages: ${error.message}`, error);
throw error;
}
}
/**
* 获取待发送的消息
*/
async getPendingMessages(limit: number = 100): Promise<PushMessage[]> {
try {
const messages = await this.messageModel.findAll({
where: {
status: PushMessageStatus.PENDING,
[Op.or]: [
{
expiry: {
[Op.or]: [
{ [Op.is]: null },
{ [Op.gt]: new Date() },
],
},
},
],
},
order: [['priority', 'DESC'], ['createdAt', 'ASC']],
limit,
});
return messages;
} catch (error) {
this.logger.error(`Failed to get pending messages: ${error.message}`, error);
throw error;
}
}
/**
* 根据设备令牌获取待发送消息
*/
async getPendingMessagesByDeviceToken(deviceToken: string): Promise<PushMessage[]> {
try {
const messages = await this.messageModel.findAll({
where: {
deviceToken,
status: PushMessageStatus.PENDING,
[Op.or]: [
{
expiry: {
[Op.or]: [
{ [Op.is]: null },
{ [Op.gt]: new Date() },
],
},
},
],
},
order: [['priority', 'DESC'], ['createdAt', 'ASC']],
});
return messages;
} catch (error) {
this.logger.error(`Failed to get pending messages by device token: ${error.message}`, error);
throw error;
}
}
/**
* 分类错误信息
*/
private categorizeError(errorMessage: string): string {
if (errorMessage.includes('Unregistered') || errorMessage.includes('BadDeviceToken')) {
return 'Invalid Token';
} else if (errorMessage.includes('DeviceTokenNotForTopic')) {
return 'Topic Mismatch';
} else if (errorMessage.includes('TooManyRequests')) {
return 'Rate Limit';
} else if (errorMessage.includes('InternalServerError')) {
return 'Server Error';
} else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
return 'Timeout';
} else {
return 'Other';
}
}
/**
* 计算平均发送时间
*/
private async calculateAverageDeliveryTime(whereClause: any): Promise<number> {
try {
const messages = await this.messageModel.findAll({
where: {
...whereClause,
status: PushMessageStatus.SENT,
sentAt: {
[Op.not]: null,
},
},
attributes: ['createdAt', 'sentAt'],
});
if (messages.length === 0) {
return 0;
}
const totalDeliveryTime = messages.reduce((sum, message) => {
if (message.sentAt && message.createdAt) {
return sum + (message.sentAt.getTime() - message.createdAt.getTime());
}
return sum;
}, 0);
return totalDeliveryTime / messages.length;
} catch (error) {
this.logger.error(`Failed to calculate average delivery time: ${error.message}`, error);
return 0;
}
}
}

View File

@@ -0,0 +1,115 @@
import { Controller, Post, Put, Delete, Body, Param, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { PushNotificationsService } from './push-notifications.service';
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';
import { Public } from '../common/decorators/public.decorator';
@ApiTags('推送通知')
@Controller('push-notifications')
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(registerTokenDto, user?.sub || '');
}
@Put('update-token')
@Public()
@ApiOperation({ summary: '更新设备推送令牌' })
@ApiResponse({ status: 200, description: '更新成功', type: UpdateTokenResponseDto })
async updateToken(
@CurrentUser() user: AccessTokenPayload,
@Body() updateTokenDto: UpdateDeviceTokenDto,
): Promise<UpdateTokenResponseDto> {
return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto);
}
@Delete('unregister-token')
@Public()
@ApiOperation({ summary: '注销设备推送令牌' })
@ApiResponse({ status: 200, description: '注销成功', type: UnregisterTokenResponseDto })
async unregisterToken(
@CurrentUser() user: AccessTokenPayload,
@Body() body: { deviceToken: string },
): Promise<UnregisterTokenResponseDto> {
return this.pushNotificationsService.unregisterToken(user?.sub || '', body.deviceToken);
}
@Post('send')
@ApiOperation({ summary: '发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotification(sendNotificationDto);
}
@Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto);
}
@Post('send-batch')
@ApiOperation({ summary: '批量发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto,
): Promise<BatchPushResponseDto> {
return this.pushNotificationsService.sendBatchNotifications(sendBatchDto);
}
@Post('send-silent')
@ApiOperation({ summary: '发送静默推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendSilentNotification(
@Body() body: { userId: string; payload: any },
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendSilentNotification(body.userId, body.payload);
}
@Post('send-to-devices')
@ApiOperation({ summary: '向指定设备发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto })
async sendNotificationToDevices(
@Body() sendToDevicesDto: SendPushToDevicesDto,
): Promise<DevicePushResponseDto> {
return this.pushNotificationsService.sendNotificationToDevices(sendToDevicesDto);
}
@Post('send-batch-to-devices')
@ApiOperation({ summary: '批量向指定设备发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto })
async sendBatchNotificationToDevices(
@Body() sendBatchToDevicesDto: SendPushToDevicesDto,
): Promise<BatchDevicePushResponseDto> {
return this.pushNotificationsService.sendBatchNotificationToDevices(sendBatchToDevicesDto);
}
}

View File

@@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { PushNotificationsController } from './push-notifications.controller';
import { PushTemplateController } from './push-template.controller';
import { PushNotificationsService } from './push-notifications.service';
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';
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,
PushTemplate,
]),
],
controllers: [
PushNotificationsController,
PushTemplateController,
],
providers: [
ApnsProvider,
PushNotificationsService,
PushTokenService,
PushTemplateService,
PushMessageService,
PushTestService,
],
exports: [
ApnsProvider,
PushNotificationsService,
PushTokenService,
PushTemplateService,
PushMessageService,
PushTestService,
],
})
export class PushNotificationsModule { }

View File

@@ -0,0 +1,790 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApnsProvider } from './apns.provider';
import { PushTokenService } from './push-token.service';
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 { PushMessageStatus } from './enums/push-message-status.enum';
import { PushType } from 'apns2';
@Injectable()
export class PushNotificationsService {
private readonly logger = new Logger(PushNotificationsService.name);
private readonly bundleId: string;
constructor(
private readonly apnsProvider: ApnsProvider,
private readonly pushTokenService: PushTokenService,
private readonly pushTemplateService: PushTemplateService,
private readonly pushMessageService: PushMessageService,
private readonly configService: ConfigService,
) {
this.bundleId = this.configService.get<string>('APNS_BUNDLE_ID') || '';
}
/**
* 发送单个推送通知
*/
async sendNotification(notificationData: SendPushNotificationDto): Promise<PushResponseDto> {
try {
this.logger.log(`Sending push notification to ${notificationData.userIds.length} users`);
const results: PushResult[] = [];
let sentCount = 0;
let failedCount = 0;
// 获取所有用户的设备令牌
const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds);
// 为每个用户创建消息记录并发送推送
for (const userId of notificationData.userIds) {
const deviceTokens = userTokensMap.get(userId) || [];
if (deviceTokens.length === 0) {
this.logger.warn(`No active device tokens found for user ${userId}`);
results.push({
userId,
deviceToken: '',
success: false,
error: 'No active device tokens found',
});
failedCount++;
continue;
}
// 为每个设备令牌创建消息记录
for (const deviceToken of deviceTokens) {
try {
// 创建消息记录
const messageData: CreatePushMessageDto = {
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({
userId,
deviceToken,
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') {
await this.pushTokenService.unregisterToken(userId, deviceToken);
}
results.push({
userId,
deviceToken,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to send push to user ${userId}, device ${deviceToken}: ${error.message}`, error);
results.push({
userId,
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: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: notificationData.userIds.length,
results: [],
},
};
}
}
/**
* 使用模板发送推送通知
*/
async sendNotificationByTemplate(templateData: SendPushByTemplateDto): Promise<PushResponseDto> {
try {
this.logger.log(`Sending push notification using template: ${templateData.templateKey}`);
// 渲染模板
const renderedTemplate = await this.pushTemplateService.renderTemplate(
templateData.templateKey,
templateData.data
);
// 构建推送数据
const notificationData: SendPushNotificationDto = {
userIds: templateData.userIds,
title: renderedTemplate.title,
body: renderedTemplate.body,
payload: { ...renderedTemplate.payload, ...templateData.payload },
pushType: renderedTemplate.pushType,
priority: renderedTemplate.priority,
collapseId: templateData.collapseId,
sound: templateData.sound,
badge: templateData.badge,
};
// 发送推送
return this.sendNotification(notificationData);
} catch (error) {
this.logger.error(`Failed to send push notification by template: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `模板推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: templateData.userIds.length,
results: [],
},
};
}
}
/**
* 批量发送推送通知
*/
async sendBatchNotifications(notificationData: SendPushNotificationDto): Promise<BatchPushResponseDto> {
try {
this.logger.log(`Sending batch push notification to ${notificationData.userIds.length} users`);
const results: PushResult[] = [];
let totalUsers = notificationData.userIds.length;
let totalTokens = 0;
let successCount = 0;
let failedCount = 0;
// 获取所有用户的设备令牌
const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds);
// 统计总令牌数
for (const tokens of userTokensMap.values()) {
totalTokens += tokens.length;
}
// 创建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 allDeviceTokens = Array.from(userTokensMap.values()).flat();
if (allDeviceTokens.length === 0) {
return {
code: ResponseCode.ERROR,
message: '没有找到有效的设备令牌',
data: {
totalUsers,
totalTokens: 0,
successCount: 0,
failedCount: totalUsers,
results: [],
},
};
}
const apnsResults = await this.apnsProvider.send(apnsNotification, allDeviceTokens);
// 处理结果并创建消息记录
for (const [userId, deviceTokens] of userTokensMap.entries()) {
for (const deviceToken of deviceTokens) {
try {
// 创建消息记录
const messageData: CreatePushMessageDto = {
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({
userId,
deviceToken,
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') {
await this.pushTokenService.unregisterToken(userId, deviceToken);
}
results.push({
userId,
deviceToken,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} else {
// 未找到结果,标记为失败
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
null,
'No APNs result found'
);
results.push({
userId,
deviceToken,
success: false,
error: 'No APNs result found',
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to process batch push result for user ${userId}, device ${deviceToken}: ${error.message}`, error);
results.push({
userId,
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '批量推送发送成功' : '部分批量推送发送失败',
data: {
totalUsers,
totalTokens,
successCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send batch push notification: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `批量推送发送失败: ${error.message}`,
data: {
totalUsers: notificationData.userIds.length,
totalTokens: 0,
successCount: 0,
failedCount: notificationData.userIds.length,
results: [],
},
};
}
}
/**
* 发送静默推送
*/
async sendSilentNotification(userId: string, payload: any): Promise<PushResponseDto> {
try {
this.logger.log(`Sending silent push notification to user ${userId}`);
const notificationData: SendPushNotificationDto = {
userIds: [userId],
title: '',
body: '',
payload,
pushType: PushType.background,
contentAvailable: true,
};
return this.sendNotification(notificationData);
} catch (error) {
this.logger.error(`Failed to send silent push notification: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `静默推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: 1,
results: [],
},
};
}
}
/**
* 注册设备令牌
*/
async registerToken(tokenData: any, userId?: string,): Promise<any> {
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: '设备令牌注册成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to register device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌注册失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/**
* 更新设备令牌
*/
async updateToken(userId: string, tokenData: any): Promise<any> {
try {
const token = await this.pushTokenService.updateToken(userId, tokenData);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌更新成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to update device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌更新失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/**
* 注销设备令牌
*/
async unregisterToken(userId: string, deviceToken: string): Promise<any> {
try {
await this.pushTokenService.unregisterToken(userId, deviceToken);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌注销成功',
data: {
success: true,
},
};
} catch (error) {
this.logger.error(`Failed to unregister device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌注销失败: ${error.message}`,
data: {
success: false,
},
};
}
}
/**
* 基于设备令牌发送推送通知
*/
async sendNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<DevicePushResponseDto> {
try {
this.logger.log(`Sending push notification to ${notificationData.deviceTokens.length} devices`);
const results: DevicePushResult[] = [];
let sentCount = 0;
let failedCount = 0;
// 为每个设备令牌创建消息记录并发送推送
for (const deviceToken of notificationData.deviceTokens) {
try {
// 尝试获取设备令牌对应的用户ID
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId: userId || '',
deviceToken,
messageType: 'manual',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
});
// 发送推送
const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]);
// 处理结果
if (apnsResults.sent.length > 0) {
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
deviceToken,
userId: userId || undefined,
success: true,
apnsResponse: apnsResults,
});
sentCount++;
} else {
const failure = apnsResults.failed[0];
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 如果是无效令牌,停用该令牌
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
if (userId) {
await this.pushTokenService.unregisterToken(userId, deviceToken);
} else {
// 如果没有用户ID直接停用令牌
await this.pushTokenService.deactivateToken(deviceToken);
}
}
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to send push to device ${deviceToken}: ${error.message}`, error);
results.push({
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '推送发送成功' : '部分推送发送失败',
data: {
success,
sentCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send push notification to devices: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: notificationData.deviceTokens.length,
results: [],
},
};
}
}
/**
* 批量基于设备令牌发送推送通知
*/
async sendBatchNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<BatchDevicePushResponseDto> {
try {
this.logger.log(`Sending batch push notification to ${notificationData.deviceTokens.length} devices`);
const results: DevicePushResult[] = [];
let totalTokens = notificationData.deviceTokens.length;
let successCount = 0;
let failedCount = 0;
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
alert: notificationData.title,
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
});
this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`);
// 批量发送推送
const apnsResults = await this.apnsProvider.send(apnsNotification, notificationData.deviceTokens);
// 处理结果并创建消息记录
for (const deviceToken of notificationData.deviceTokens) {
try {
// 尝试获取设备令牌对应的用户ID
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId: userId || '',
deviceToken,
messageType: 'batch',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 查找对应的APNs结果
const apnsResult = apnsResults.sent.includes(deviceToken) ?
{ device: deviceToken, success: true } :
apnsResults.failed.find(f => f.device === deviceToken);
if (apnsResult) {
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
// 成功发送
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
deviceToken,
userId: userId || undefined,
success: true,
apnsResponse: apnsResult,
});
successCount++;
} else {
// 发送失败
const failure = apnsResult as any;
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 如果是无效令牌,停用该令牌
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
if (userId) {
await this.pushTokenService.unregisterToken(userId, deviceToken);
} else {
// 如果没有用户ID直接停用令牌
await this.pushTokenService.deactivateToken(deviceToken);
}
}
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} else {
// 未找到结果,标记为失败
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
null,
'No APNs result found'
);
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: 'No APNs result found',
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to process batch push result for device ${deviceToken}: ${error.message}`, error);
results.push({
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '批量推送发送成功' : '部分批量推送发送失败',
data: {
totalTokens,
successCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send batch push notification to devices: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `批量推送发送失败: ${error.message}`,
data: {
totalTokens: notificationData.deviceTokens.length,
successCount: 0,
failedCount: notificationData.deviceTokens.length,
results: [],
},
};
}
}
}

View File

@@ -0,0 +1,104 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { PushTemplateService } from './push-template.service';
import { CreatePushTemplateDto } from './dto/create-push-template.dto';
import { UpdatePushTemplateDto } from './dto/update-push-template.dto';
import { PushTemplate } from './models/push-template.model';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@ApiTags('推送模板')
@Controller('push-notifications/templates')
@UseGuards(JwtAuthGuard)
export class PushTemplateController {
constructor(private readonly pushTemplateService: PushTemplateService) { }
@Get()
@ApiOperation({ summary: '获取所有推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] })
async getAllTemplates(): Promise<PushTemplate[]> {
return this.pushTemplateService.getAllTemplates();
}
@Get('active')
@ApiOperation({ summary: '获取所有活跃推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] })
async getActiveTemplates(): Promise<PushTemplate[]> {
return this.pushTemplateService.getActiveTemplates();
}
@Get(':templateKey')
@ApiOperation({ summary: '获取推送模板' })
@ApiParam({ name: 'templateKey', description: '模板键' })
@ApiResponse({ status: 200, description: '获取成功', type: PushTemplate })
async getTemplate(@Param('templateKey') templateKey: string): Promise<PushTemplate> {
return this.pushTemplateService.getTemplate(templateKey);
}
@Get('id/:id')
@ApiOperation({ summary: '根据ID获取推送模板' })
@ApiParam({ name: 'id', description: '模板ID' })
@ApiResponse({ status: 200, description: '获取成功', type: PushTemplate })
async getTemplateById(@Param('id') id: string): Promise<PushTemplate> {
return this.pushTemplateService.getTemplateById(id);
}
@Post()
@ApiOperation({ summary: '创建推送模板' })
@ApiResponse({ status: 201, description: '创建成功', type: PushTemplate })
async createTemplate(
@Body() createTemplateDto: CreatePushTemplateDto,
): Promise<PushTemplate> {
return this.pushTemplateService.createTemplate(createTemplateDto);
}
@Put(':id')
@ApiOperation({ summary: '更新推送模板' })
@ApiParam({ name: 'id', description: '模板ID' })
@ApiResponse({ status: 200, description: '更新成功', type: PushTemplate })
async updateTemplate(
@Param('id') id: string,
@Body() updateTemplateDto: UpdatePushTemplateDto,
): Promise<PushTemplate> {
return this.pushTemplateService.updateTemplate(id, updateTemplateDto);
}
@Delete(':id')
@ApiOperation({ summary: '删除推送模板' })
@ApiParam({ name: 'id', description: '模板ID' })
@ApiResponse({ status: 200, description: '删除成功' })
async deleteTemplate(@Param('id') id: string): Promise<void> {
return this.pushTemplateService.deleteTemplate(id);
}
@Put(':id/toggle')
@ApiOperation({ summary: '激活/停用模板' })
@ApiParam({ name: 'id', description: '模板ID' })
@ApiResponse({ status: 200, description: '操作成功', type: PushTemplate })
async toggleTemplateStatus(
@Param('id') id: string,
@Body() body: { isActive: boolean },
): Promise<PushTemplate> {
return this.pushTemplateService.toggleTemplateStatus(id, body.isActive);
}
@Post('validate')
@ApiOperation({ summary: '验证模板变量' })
@ApiResponse({ status: 200, description: '验证成功' })
async validateTemplateVariables(
@Body() body: { template: string; requiredVariables: string[] },
): Promise<{ isValid: boolean; missingVariables: string[] }> {
return this.pushTemplateService.validateTemplateVariables(body.template, body.requiredVariables);
}
@Post('extract-variables')
@ApiOperation({ summary: '提取模板变量' })
@ApiResponse({ status: 200, description: '提取成功' })
async extractTemplateVariables(
@Body() body: { template: string },
): Promise<{ variables: string[] }> {
const variables = this.pushTemplateService.extractTemplateVariables(body.template);
return { variables };
}
}

View File

@@ -0,0 +1,280 @@
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { PushTemplate } from './models/push-template.model';
import { CreatePushTemplateDto } from './dto/create-push-template.dto';
import { UpdatePushTemplateDto } from './dto/update-push-template.dto';
import { RenderedTemplate } from './interfaces/push-notification.interface';
@Injectable()
export class PushTemplateService {
private readonly logger = new Logger(PushTemplateService.name);
constructor(
@InjectModel(PushTemplate)
private readonly templateModel: typeof PushTemplate,
) { }
/**
* 创建推送模板
*/
async createTemplate(templateData: CreatePushTemplateDto): Promise<PushTemplate> {
try {
this.logger.log(`Creating push template with key: ${templateData.templateKey}`);
// 检查模板键是否已存在
const existingTemplate = await this.templateModel.findOne({
where: {
templateKey: templateData.templateKey,
},
});
if (existingTemplate) {
throw new ConflictException(`Template with key '${templateData.templateKey}' already exists`);
}
const template = await this.templateModel.create({
templateKey: templateData.templateKey,
title: templateData.title,
body: templateData.body,
payloadTemplate: templateData.payloadTemplate,
pushType: templateData.pushType,
priority: templateData.priority || 10,
isActive: true,
});
this.logger.log(`Successfully created push template with key: ${templateData.templateKey}`);
return template;
} catch (error) {
this.logger.error(`Failed to create push template: ${error.message}`, error);
throw error;
}
}
/**
* 更新推送模板
*/
async updateTemplate(id: string, templateData: UpdatePushTemplateDto): Promise<PushTemplate> {
try {
this.logger.log(`Updating push template with ID: ${id}`);
const template = await this.templateModel.findByPk(id);
if (!template) {
throw new NotFoundException(`Template with ID ${id} not found`);
}
await template.update(templateData);
this.logger.log(`Successfully updated push template with ID: ${id}`);
return template;
} catch (error) {
this.logger.error(`Failed to update push template: ${error.message}`, error);
throw error;
}
}
/**
* 删除推送模板
*/
async deleteTemplate(id: string): Promise<void> {
try {
this.logger.log(`Deleting push template with ID: ${id}`);
const template = await this.templateModel.findByPk(id);
if (!template) {
throw new NotFoundException(`Template with ID ${id} not found`);
}
await template.destroy();
this.logger.log(`Successfully deleted push template with ID: ${id}`);
} catch (error) {
this.logger.error(`Failed to delete push template: ${error.message}`, error);
throw error;
}
}
/**
* 获取模板
*/
async getTemplate(templateKey: string): Promise<PushTemplate> {
try {
const template = await this.templateModel.findOne({
where: {
templateKey,
isActive: true,
},
});
if (!template) {
throw new NotFoundException(`Template with key '${templateKey}' not found or inactive`);
}
return template;
} catch (error) {
this.logger.error(`Failed to get push template: ${error.message}`, error);
throw error;
}
}
/**
* 根据ID获取模板
*/
async getTemplateById(id: string): Promise<PushTemplate> {
try {
const template = await this.templateModel.findByPk(id);
if (!template) {
throw new NotFoundException(`Template with ID ${id} not found`);
}
return template;
} catch (error) {
this.logger.error(`Failed to get push template by ID: ${error.message}`, error);
throw error;
}
}
/**
* 获取所有模板
*/
async getAllTemplates(): Promise<PushTemplate[]> {
try {
const templates = await this.templateModel.findAll({
order: [['createdAt', 'DESC']],
});
return templates;
} catch (error) {
this.logger.error(`Failed to get all push templates: ${error.message}`, error);
throw error;
}
}
/**
* 获取所有活跃模板
*/
async getActiveTemplates(): Promise<PushTemplate[]> {
try {
const templates = await this.templateModel.findAll({
where: {
isActive: true,
},
order: [['createdAt', 'DESC']],
});
return templates;
} catch (error) {
this.logger.error(`Failed to get active push templates: ${error.message}`, error);
throw error;
}
}
/**
* 渲染模板
*/
async renderTemplate(templateKey: string, data: any): Promise<RenderedTemplate> {
try {
this.logger.log(`Rendering template with key: ${templateKey}`);
const template = await this.getTemplate(templateKey);
// 简单的模板变量替换
const renderedTitle = this.replaceVariables(template.title, data);
const renderedBody = this.replaceVariables(template.body, data);
const renderedPayload = template.payloadTemplate
? this.replaceVariables(JSON.stringify(template.payloadTemplate), data)
: null;
const renderedTemplate: RenderedTemplate = {
title: renderedTitle,
body: renderedBody,
payload: renderedPayload ? JSON.parse(renderedPayload) : undefined,
pushType: template.pushType,
priority: template.priority,
};
this.logger.log(`Successfully rendered template with key: ${templateKey}`);
return renderedTemplate;
} catch (error) {
this.logger.error(`Failed to render template: ${error.message}`, error);
throw error;
}
}
/**
* 激活/停用模板
*/
async toggleTemplateStatus(id: string, isActive: boolean): Promise<PushTemplate> {
try {
this.logger.log(`Toggling template status for ID: ${id} to ${isActive}`);
const template = await this.templateModel.findByPk(id);
if (!template) {
throw new NotFoundException(`Template with ID ${id} not found`);
}
await template.update({
isActive,
});
this.logger.log(`Successfully toggled template status for ID: ${id}`);
return template;
} catch (error) {
this.logger.error(`Failed to toggle template status: ${error.message}`, error);
throw error;
}
}
/**
* 替换模板变量
*/
private replaceVariables(template: string, data: any): string {
if (!template || !data) {
return template;
}
let result = template;
// 替换 {{variable}} 格式的变量
Object.keys(data).forEach(key => {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
result = result.replace(regex, data[key]);
});
return result;
}
/**
* 验证模板变量
*/
validateTemplateVariables(template: string, requiredVariables: string[]): { isValid: boolean; missingVariables: string[] } {
const variableRegex = /{{\s*([^}]+)\s*}}/g;
const foundVariables: string[] = [];
let match;
while ((match = variableRegex.exec(template)) !== null) {
foundVariables.push(match[1].trim());
}
const missingVariables = requiredVariables.filter(variable => !foundVariables.includes(variable));
return {
isValid: missingVariables.length === 0,
missingVariables,
};
}
/**
* 获取模板变量列表
*/
extractTemplateVariables(template: string): string[] {
const variableRegex = /{{\s*([^}]+)\s*}}/g;
const variables: string[] = [];
let match;
while ((match = variableRegex.exec(template)) !== null) {
variables.push(match[1].trim());
}
return [...new Set(variables)]; // 去重
}
}

View File

@@ -0,0 +1,99 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PushNotificationsService } from './push-notifications.service';
import { PushTokenService } from './push-token.service';
import { UserPushToken } from './models/user-push-token.model';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { PushType } from 'apns2';
@Injectable()
export class PushTestService implements OnModuleInit {
private readonly logger = new Logger(PushTestService.name);
constructor(
@InjectModel(UserPushToken)
private readonly pushTokenModel: typeof UserPushToken,
private readonly pushNotificationsService: PushNotificationsService,
private readonly pushTokenService: PushTokenService,
private readonly configService: ConfigService,
) { }
/**
* 模块初始化时执行
*/
async onModuleInit() {
// 检查是否启用推送测试
const enablePushTest = this.configService.get<boolean>('ENABLE_PUSH_TEST', false);
if (!enablePushTest) {
this.logger.log('Push test is disabled. Skipping...');
return;
}
// 延迟执行,确保应用完全启动
setTimeout(async () => {
try {
await this.performPushTest();
} catch (error) {
this.logger.error(`Push test failed: ${error.message}`, error);
}
}, 5000); // 5秒后执行
}
/**
* 执行推送测试
*/
private async performPushTest(): Promise<void> {
this.logger.log('Starting push test...');
try {
// 获取所有活跃的推送令牌
const activeTokens = await this.pushTokenModel.findAll({
where: {
isActive: true,
},
limit: 10, // 限制测试数量,避免发送过多推送
});
if (activeTokens.length === 0) {
this.logger.log('No active push tokens found for testing');
return;
}
this.logger.log(`Found ${activeTokens.length} active tokens for testing`);
// 准备测试推送内容
const testTitle = this.configService.get<string>('PUSH_TEST_TITLE', '测试推送');
const testBody = this.configService.get<string>('PUSH_TEST_BODY', '这是一条测试推送消息,用于验证推送功能是否正常工作。');
// 发送测试推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
deviceTokens: activeTokens.map(token => token.deviceToken),
title: testTitle,
body: testBody,
pushType: PushType.alert,
});
if (result.code === 0) {
this.logger.log(`Push test completed successfully. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
} else {
this.logger.warn(`Push test completed with issues. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
}
// 记录详细结果
if (result.data.results && result.data.results.length > 0) {
result.data.results.forEach((resultItem, index) => {
if (resultItem.success) {
this.logger.log(`Push test success for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...`);
} else {
this.logger.warn(`Push test failed for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...: ${resultItem.error}`);
}
});
}
} catch (error) {
this.logger.error(`Error during push test: ${error.message}`, error);
}
}
}

View File

@@ -0,0 +1,354 @@
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { UserPushToken } from './models/user-push-token.model';
import { DeviceType } from './enums/device-type.enum';
import { RegisterDeviceTokenDto } from './dto/register-device-token.dto';
import { UpdateDeviceTokenDto } from './dto/update-device-token.dto';
@Injectable()
export class PushTokenService {
private readonly logger = new Logger(PushTokenService.name);
constructor(
@InjectModel(UserPushToken)
private readonly pushTokenModel: typeof UserPushToken,
) { }
/**
* 注册设备令牌
*/
async registerToken(tokenData: RegisterDeviceTokenDto, userId?: string): Promise<UserPushToken> {
try {
this.logger.log(`Registering push token for device ${tokenData.deviceToken}`);
// 检查是否已存在相同的令牌
const existingToken = await this.pushTokenModel.findOne({
where: {
deviceToken: tokenData.deviceToken,
},
});
if (existingToken) {
// 更新现有令牌信息
await existingToken.update({
deviceType: tokenData.deviceType,
appVersion: tokenData.appVersion,
osVersion: tokenData.osVersion,
deviceName: tokenData.deviceName,
isActive: true,
lastUsedAt: new Date(),
});
this.logger.log(`Updated existing push token for device ${tokenData.deviceToken}`);
return existingToken;
}
// 创建新令牌
const newToken = await this.pushTokenModel.create({
userId,
deviceToken: tokenData.deviceToken,
deviceType: tokenData.deviceType,
appVersion: tokenData.appVersion,
osVersion: tokenData.osVersion,
deviceName: tokenData.deviceName,
isActive: true,
lastUsedAt: new Date(),
});
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 device ${tokenData.deviceToken}: ${error.message}`, error);
throw error;
}
}
/**
* 更新设备令牌
*/
async updateToken(userId: string, tokenData: UpdateDeviceTokenDto): Promise<UserPushToken> {
try {
this.logger.log(`Updating push token for user ${userId}`);
// 查找当前令牌
const currentToken = await this.pushTokenModel.findOne({
where: {
userId,
deviceToken: tokenData.currentDeviceToken,
isActive: true,
},
});
if (!currentToken) {
throw new NotFoundException('Current device token not found or inactive');
}
// 检查新令牌是否已存在
const existingNewToken = await this.pushTokenModel.findOne({
where: {
userId,
deviceToken: tokenData.newDeviceToken,
},
});
if (existingNewToken) {
// 如果新令牌已存在,激活它并停用当前令牌
await existingNewToken.update({
isActive: true,
lastUsedAt: new Date(),
appVersion: tokenData.appVersion || existingNewToken.appVersion,
osVersion: tokenData.osVersion || existingNewToken.osVersion,
deviceName: tokenData.deviceName || existingNewToken.deviceName,
});
await currentToken.update({
isActive: false,
});
this.logger.log(`Activated existing new token and deactivated old token for user ${userId}`);
return existingNewToken;
}
// 更新当前令牌为新令牌
await currentToken.update({
deviceToken: tokenData.newDeviceToken,
appVersion: tokenData.appVersion,
osVersion: tokenData.osVersion,
deviceName: tokenData.deviceName,
lastUsedAt: new Date(),
});
this.logger.log(`Successfully updated push token for user ${userId}`);
return currentToken;
} catch (error) {
this.logger.error(`Failed to update push token for user ${userId}: ${error.message}`, error);
throw error;
}
}
/**
* 注销设备令牌
*/
async unregisterToken(userId: string, deviceToken: string): Promise<void> {
try {
this.logger.log(`Unregistering push token for user ${userId}`);
const token = await this.pushTokenModel.findOne({
where: {
userId,
deviceToken,
isActive: true,
},
});
if (!token) {
throw new NotFoundException('Device token not found or inactive');
}
await token.update({
isActive: false,
});
this.logger.log(`Successfully unregistered push token for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to unregister push token for user ${userId}: ${error.message}`, error);
throw error;
}
}
/**
* 获取用户的所有有效令牌
*/
async getActiveTokens(userId: string): Promise<UserPushToken[]> {
try {
const tokens = await this.pushTokenModel.findAll({
where: {
userId,
isActive: true,
},
order: [['lastUsedAt', 'DESC']],
});
this.logger.log(`Found ${tokens.length} active tokens for user ${userId}`);
return tokens;
} catch (error) {
this.logger.error(`Failed to get active tokens for user ${userId}: ${error.message}`, error);
throw error;
}
}
/**
* 获取用户的所有令牌(包括非活跃的)
*/
async getAllTokens(userId: string): Promise<UserPushToken[]> {
try {
const tokens = await this.pushTokenModel.findAll({
where: {
userId,
},
order: [['createdAt', 'DESC']],
});
this.logger.log(`Found ${tokens.length} total tokens for user ${userId}`);
return tokens;
} catch (error) {
this.logger.error(`Failed to get all tokens for user ${userId}: ${error.message}`, error);
throw error;
}
}
/**
* 验证令牌有效性
*/
async validateToken(deviceToken: string): Promise<boolean> {
try {
const token = await this.pushTokenModel.findOne({
where: {
deviceToken,
isActive: true,
},
});
return !!token;
} catch (error) {
this.logger.error(`Failed to validate token: ${error.message}`, error);
return false;
}
}
/**
* 清理无效令牌
*/
async cleanupInvalidTokens(): Promise<number> {
try {
this.logger.log('Starting cleanup of invalid tokens');
// 清理超过30天未使用的令牌
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.pushTokenModel.update(
{
isActive: false,
},
{
where: {
isActive: true,
lastUsedAt: {
[Op.lt]: thirtyDaysAgo,
},
},
},
);
const cleanedCount = result[0];
this.logger.log(`Cleaned up ${cleanedCount} inactive tokens`);
return cleanedCount;
} catch (error) {
this.logger.error(`Failed to cleanup invalid tokens: ${error.message}`, error);
throw error;
}
}
/**
* 根据设备令牌获取用户ID
*/
async getUserIdByDeviceToken(deviceToken: string): Promise<string | null> {
try {
const token = await this.pushTokenModel.findOne({
where: {
deviceToken,
isActive: true,
},
});
return token ? token.userId : null;
} catch (error) {
this.logger.error(`Failed to get user ID by device token: ${error.message}`, error);
return null;
}
}
/**
* 批量获取用户的设备令牌
*/
async getDeviceTokensByUserIds(userIds: string[]): Promise<Map<string, string[]>> {
try {
const tokens = await this.pushTokenModel.findAll({
where: {
userId: {
[Op.in]: userIds,
},
isActive: true,
},
});
const userTokensMap = new Map<string, string[]>();
tokens.forEach((token) => {
if (!userTokensMap.has(token.userId)) {
userTokensMap.set(token.userId, []);
}
userTokensMap.get(token.userId)!.push(token.deviceToken);
});
return userTokensMap;
} catch (error) {
this.logger.error(`Failed to get device tokens by user IDs: ${error.message}`, error);
throw error;
}
}
/**
* 更新令牌最后使用时间
*/
async updateLastUsedTime(deviceToken: string): Promise<void> {
try {
await this.pushTokenModel.update(
{
lastUsedAt: new Date(),
},
{
where: {
deviceToken,
isActive: true,
},
},
);
} catch (error) {
this.logger.error(`Failed to update last used time: ${error.message}`, error);
}
}
/**
* 直接停用设备令牌不需要用户ID
*/
async deactivateToken(deviceToken: string): Promise<void> {
try {
this.logger.log(`Deactivating push token: ${deviceToken}`);
const token = await this.pushTokenModel.findOne({
where: {
deviceToken,
isActive: true,
},
});
if (!token) {
this.logger.warn(`Device token not found or already inactive: ${deviceToken}`);
return;
}
await token.update({
isActive: false,
});
this.logger.log(`Successfully deactivated push token: ${deviceToken}`);
} catch (error) {
this.logger.error(`Failed to deactivate push token: ${deviceToken}: ${error.message}`, error);
throw error;
}
}
}