451 lines
12 KiB
JavaScript
451 lines
12 KiB
JavaScript
// src/services/notificationMetricsService.js
|
||
/**
|
||
* 通知性能监控服务
|
||
* 追踪推送到达率、点击率、响应时间等指标
|
||
*/
|
||
|
||
import { logger } from '../utils/logger';
|
||
|
||
const STORAGE_KEY = 'notification_metrics';
|
||
const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
|
||
|
||
class NotificationMetricsService {
|
||
constructor() {
|
||
this.metrics = this.loadMetrics();
|
||
}
|
||
|
||
/**
|
||
* 从 localStorage 加载指标数据
|
||
*/
|
||
loadMetrics() {
|
||
try {
|
||
const data = localStorage.getItem(STORAGE_KEY);
|
||
if (data) {
|
||
const parsed = JSON.parse(data);
|
||
// 清理过期数据
|
||
this.cleanOldData(parsed);
|
||
return parsed;
|
||
}
|
||
} catch (error) {
|
||
logger.error('notificationMetricsService', 'loadMetrics', error);
|
||
}
|
||
|
||
// 返回默认结构
|
||
return this.getDefaultMetrics();
|
||
}
|
||
|
||
/**
|
||
* 获取默认指标结构
|
||
*/
|
||
getDefaultMetrics() {
|
||
return {
|
||
summary: {
|
||
totalSent: 0,
|
||
totalReceived: 0,
|
||
totalClicked: 0,
|
||
totalDismissed: 0,
|
||
totalResponseTime: 0, // 总响应时间(毫秒)
|
||
},
|
||
byType: {
|
||
announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
},
|
||
byPriority: {
|
||
urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||
},
|
||
hourlyDistribution: Array(24).fill(0), // 每小时推送分布
|
||
dailyData: {}, // 按日期存储的每日数据
|
||
lastUpdated: Date.now(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 保存指标数据到 localStorage
|
||
*/
|
||
saveMetrics() {
|
||
try {
|
||
this.metrics.lastUpdated = Date.now();
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
|
||
logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
|
||
} catch (error) {
|
||
logger.error('notificationMetricsService', 'saveMetrics', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理过期数据
|
||
*/
|
||
cleanOldData(metrics) {
|
||
const cutoffDate = new Date();
|
||
cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
|
||
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
|
||
|
||
if (metrics.dailyData) {
|
||
Object.keys(metrics.dailyData).forEach(date => {
|
||
if (date < cutoffDateStr) {
|
||
delete metrics.dailyData[date];
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前日期字符串
|
||
*/
|
||
getTodayDateStr() {
|
||
return new Date().toISOString().split('T')[0];
|
||
}
|
||
|
||
/**
|
||
* 获取当前小时
|
||
*/
|
||
getCurrentHour() {
|
||
return new Date().getHours();
|
||
}
|
||
|
||
/**
|
||
* 确保每日数据结构存在
|
||
*/
|
||
ensureDailyData(dateStr) {
|
||
if (!this.metrics.dailyData[dateStr]) {
|
||
this.metrics.dailyData[dateStr] = {
|
||
sent: 0,
|
||
received: 0,
|
||
clicked: 0,
|
||
dismissed: 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 追踪通知发送
|
||
* @param {object} notification - 通知对象
|
||
*/
|
||
trackSent(notification) {
|
||
const { type, priority } = notification;
|
||
const today = this.getTodayDateStr();
|
||
const hour = this.getCurrentHour();
|
||
|
||
// 汇总统计
|
||
this.metrics.summary.totalSent++;
|
||
|
||
// 按类型统计
|
||
if (this.metrics.byType[type]) {
|
||
this.metrics.byType[type].sent++;
|
||
}
|
||
|
||
// 按优先级统计
|
||
if (priority && this.metrics.byPriority[priority]) {
|
||
this.metrics.byPriority[priority].sent++;
|
||
}
|
||
|
||
// 每小时分布
|
||
this.metrics.hourlyDistribution[hour]++;
|
||
|
||
// 每日数据
|
||
this.ensureDailyData(today);
|
||
this.metrics.dailyData[today].sent++;
|
||
|
||
this.saveMetrics();
|
||
|
||
logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
|
||
}
|
||
|
||
/**
|
||
* 追踪通知接收
|
||
* @param {object} notification - 通知对象
|
||
*/
|
||
trackReceived(notification) {
|
||
const { type, priority, id, timestamp } = notification;
|
||
const today = this.getTodayDateStr();
|
||
|
||
// 汇总统计
|
||
this.metrics.summary.totalReceived++;
|
||
|
||
// 按类型统计
|
||
if (this.metrics.byType[type]) {
|
||
this.metrics.byType[type].received++;
|
||
}
|
||
|
||
// 按优先级统计
|
||
if (priority && this.metrics.byPriority[priority]) {
|
||
this.metrics.byPriority[priority].received++;
|
||
}
|
||
|
||
// 每日数据
|
||
this.ensureDailyData(today);
|
||
this.metrics.dailyData[today].received++;
|
||
|
||
// 存储接收时间(用于计算响应时间)
|
||
this.storeReceivedTime(id, timestamp || Date.now());
|
||
|
||
this.saveMetrics();
|
||
|
||
logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
|
||
}
|
||
|
||
/**
|
||
* 追踪通知点击
|
||
* @param {object} notification - 通知对象
|
||
*/
|
||
trackClicked(notification) {
|
||
const { type, priority, id } = notification;
|
||
const today = this.getTodayDateStr();
|
||
const clickTime = Date.now();
|
||
|
||
// 汇总统计
|
||
this.metrics.summary.totalClicked++;
|
||
|
||
// 按类型统计
|
||
if (this.metrics.byType[type]) {
|
||
this.metrics.byType[type].clicked++;
|
||
}
|
||
|
||
// 按优先级统计
|
||
if (priority && this.metrics.byPriority[priority]) {
|
||
this.metrics.byPriority[priority].clicked++;
|
||
}
|
||
|
||
// 每日数据
|
||
this.ensureDailyData(today);
|
||
this.metrics.dailyData[today].clicked++;
|
||
|
||
// 计算响应时间
|
||
const receivedTime = this.getReceivedTime(id);
|
||
if (receivedTime) {
|
||
const responseTime = clickTime - receivedTime;
|
||
this.metrics.summary.totalResponseTime += responseTime;
|
||
this.removeReceivedTime(id);
|
||
logger.debug('notificationMetricsService', 'Response time', { responseTime });
|
||
}
|
||
|
||
this.saveMetrics();
|
||
|
||
logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
|
||
}
|
||
|
||
/**
|
||
* 追踪通知关闭
|
||
* @param {object} notification - 通知对象
|
||
*/
|
||
trackDismissed(notification) {
|
||
const { type, priority, id } = notification;
|
||
const today = this.getTodayDateStr();
|
||
|
||
// 汇总统计
|
||
this.metrics.summary.totalDismissed++;
|
||
|
||
// 按类型统计
|
||
if (this.metrics.byType[type]) {
|
||
this.metrics.byType[type].dismissed++;
|
||
}
|
||
|
||
// 按优先级统计
|
||
if (priority && this.metrics.byPriority[priority]) {
|
||
this.metrics.byPriority[priority].dismissed++;
|
||
}
|
||
|
||
// 每日数据
|
||
this.ensureDailyData(today);
|
||
this.metrics.dailyData[today].dismissed++;
|
||
|
||
// 清理接收时间记录
|
||
this.removeReceivedTime(id);
|
||
|
||
this.saveMetrics();
|
||
|
||
logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
|
||
}
|
||
|
||
/**
|
||
* 存储通知接收时间(用于计算响应时间)
|
||
*/
|
||
storeReceivedTime(id, timestamp) {
|
||
if (!this.receivedTimes) {
|
||
this.receivedTimes = new Map();
|
||
}
|
||
this.receivedTimes.set(id, timestamp);
|
||
}
|
||
|
||
/**
|
||
* 获取通知接收时间
|
||
*/
|
||
getReceivedTime(id) {
|
||
if (!this.receivedTimes) {
|
||
return null;
|
||
}
|
||
return this.receivedTimes.get(id);
|
||
}
|
||
|
||
/**
|
||
* 移除通知接收时间记录
|
||
*/
|
||
removeReceivedTime(id) {
|
||
if (this.receivedTimes) {
|
||
this.receivedTimes.delete(id);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取汇总统计
|
||
*/
|
||
getSummary() {
|
||
const summary = { ...this.metrics.summary };
|
||
|
||
// 计算平均响应时间
|
||
if (summary.totalClicked > 0) {
|
||
summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
|
||
} else {
|
||
summary.avgResponseTime = 0;
|
||
}
|
||
|
||
// 计算点击率
|
||
if (summary.totalReceived > 0) {
|
||
summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
|
||
} else {
|
||
summary.clickRate = '0.00';
|
||
}
|
||
|
||
// 计算到达率(假设 sent = received)
|
||
if (summary.totalSent > 0) {
|
||
summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
|
||
} else {
|
||
summary.deliveryRate = '100.00';
|
||
}
|
||
|
||
return summary;
|
||
}
|
||
|
||
/**
|
||
* 获取按类型统计
|
||
*/
|
||
getByType() {
|
||
const result = {};
|
||
Object.keys(this.metrics.byType).forEach(type => {
|
||
const data = this.metrics.byType[type];
|
||
result[type] = {
|
||
...data,
|
||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 获取按优先级统计
|
||
*/
|
||
getByPriority() {
|
||
const result = {};
|
||
Object.keys(this.metrics.byPriority).forEach(priority => {
|
||
const data = this.metrics.byPriority[priority];
|
||
result[priority] = {
|
||
...data,
|
||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 获取每小时分布
|
||
*/
|
||
getHourlyDistribution() {
|
||
return [...this.metrics.hourlyDistribution];
|
||
}
|
||
|
||
/**
|
||
* 获取每日数据
|
||
* @param {number} days - 获取最近多少天的数据
|
||
*/
|
||
getDailyData(days = 7) {
|
||
const result = [];
|
||
const today = new Date();
|
||
|
||
for (let i = days - 1; i >= 0; i--) {
|
||
const date = new Date(today);
|
||
date.setDate(date.getDate() - i);
|
||
const dateStr = date.toISOString().split('T')[0];
|
||
|
||
const data = this.metrics.dailyData[dateStr] || {
|
||
sent: 0,
|
||
received: 0,
|
||
clicked: 0,
|
||
dismissed: 0,
|
||
};
|
||
|
||
result.push({
|
||
date: dateStr,
|
||
...data,
|
||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 获取完整指标数据
|
||
*/
|
||
getAllMetrics() {
|
||
return {
|
||
summary: this.getSummary(),
|
||
byType: this.getByType(),
|
||
byPriority: this.getByPriority(),
|
||
hourlyDistribution: this.getHourlyDistribution(),
|
||
dailyData: this.getDailyData(30),
|
||
lastUpdated: this.metrics.lastUpdated,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 重置所有指标
|
||
*/
|
||
reset() {
|
||
this.metrics = this.getDefaultMetrics();
|
||
this.saveMetrics();
|
||
logger.info('notificationMetricsService', 'Metrics reset');
|
||
}
|
||
|
||
/**
|
||
* 导出指标数据为 JSON
|
||
*/
|
||
exportToJSON() {
|
||
return JSON.stringify(this.getAllMetrics(), null, 2);
|
||
}
|
||
|
||
/**
|
||
* 导出指标数据为 CSV
|
||
*/
|
||
exportToCSV() {
|
||
const summary = this.getSummary();
|
||
const dailyData = this.getDailyData(30);
|
||
|
||
let csv = '# Summary\n';
|
||
csv += 'Metric,Value\n';
|
||
csv += `Total Sent,${summary.totalSent}\n`;
|
||
csv += `Total Received,${summary.totalReceived}\n`;
|
||
csv += `Total Clicked,${summary.totalClicked}\n`;
|
||
csv += `Total Dismissed,${summary.totalDismissed}\n`;
|
||
csv += `Delivery Rate,${summary.deliveryRate}%\n`;
|
||
csv += `Click Rate,${summary.clickRate}%\n`;
|
||
csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
|
||
|
||
csv += '# Daily Data\n';
|
||
csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
|
||
dailyData.forEach(day => {
|
||
csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
|
||
});
|
||
|
||
return csv;
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const notificationMetricsService = new NotificationMetricsService();
|
||
|
||
export default notificationMetricsService;
|