Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
583 lines
17 KiB
TypeScript
583 lines
17 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
|
|
import { AnalyticsRecord } from '@database/entities/analytics-record.entity';
|
|
import { AnalyticsSummary } from '@database/entities/analytics-summary.entity';
|
|
import { AnalyticsQueryDto, CreateAnalyticsRecordDto } from '../dto/analytics-query.dto';
|
|
|
|
@Injectable()
|
|
export class AnalyticsService {
|
|
private readonly logger = new Logger(AnalyticsService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(AnalyticsRecord)
|
|
private readonly analyticsRecordRepository: Repository<AnalyticsRecord>,
|
|
@InjectRepository(AnalyticsSummary)
|
|
private readonly analyticsSummaryRepository: Repository<AnalyticsSummary>,
|
|
) {}
|
|
|
|
/**
|
|
* 记录分析事件
|
|
*/
|
|
async recordEvent(createDto: CreateAnalyticsRecordDto, request?: any): Promise<AnalyticsRecord> {
|
|
const now = new Date();
|
|
|
|
const record = this.analyticsRecordRepository.create({
|
|
...createDto,
|
|
timestamp: now,
|
|
date: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
|
ipAddress: request?.ip,
|
|
userAgent: request?.headers?.['user-agent'],
|
|
sessionId: request?.headers?.['x-session-id'],
|
|
requestId: request?.headers?.['x-request-id'],
|
|
});
|
|
|
|
const savedRecord = await this.analyticsRecordRepository.save(record);
|
|
this.logger.debug(`记录分析事件: ${createDto.eventType} - ${createDto.eventName}`);
|
|
|
|
return savedRecord;
|
|
}
|
|
|
|
/**
|
|
* 批量记录事件
|
|
*/
|
|
async recordEvents(events: CreateAnalyticsRecordDto[], request?: any): Promise<AnalyticsRecord[]> {
|
|
const now = new Date();
|
|
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
const records = events.map(event =>
|
|
this.analyticsRecordRepository.create({
|
|
...event,
|
|
timestamp: now,
|
|
date,
|
|
ipAddress: request?.ip,
|
|
userAgent: request?.headers?.['user-agent'],
|
|
sessionId: request?.headers?.['x-session-id'],
|
|
requestId: request?.headers?.['x-request-id'],
|
|
})
|
|
);
|
|
|
|
const savedRecords = await this.analyticsRecordRepository.save(records);
|
|
this.logger.debug(`批量记录分析事件: ${events.length} 个`);
|
|
|
|
return savedRecords;
|
|
}
|
|
|
|
/**
|
|
* 查询分析数据
|
|
*/
|
|
async queryAnalytics(queryDto: AnalyticsQueryDto): Promise<any> {
|
|
const {
|
|
metricType,
|
|
entityType,
|
|
entityId,
|
|
startDate,
|
|
endDate,
|
|
period = 'day',
|
|
groupBy,
|
|
aggregation = 'count',
|
|
limit = 1000,
|
|
} = queryDto;
|
|
|
|
// 构建查询条件
|
|
const whereConditions: any = {
|
|
eventName: metricType,
|
|
timestamp: Between(new Date(startDate), new Date(endDate)),
|
|
};
|
|
|
|
if (entityType) {
|
|
whereConditions.entityType = entityType;
|
|
}
|
|
|
|
if (entityId) {
|
|
whereConditions.entityId = entityId;
|
|
}
|
|
|
|
// 如果有预计算的汇总数据,优先使用
|
|
if (!groupBy && period && ['day', 'week', 'month'].includes(period)) {
|
|
const summaryData = await this.querySummaryData(queryDto);
|
|
if (summaryData.length > 0) {
|
|
return this.formatSummaryData(summaryData, period);
|
|
}
|
|
}
|
|
|
|
// 实时查询原始数据
|
|
return await this.queryRawData(queryDto);
|
|
}
|
|
|
|
/**
|
|
* 查询汇总数据
|
|
*/
|
|
private async querySummaryData(queryDto: AnalyticsQueryDto): Promise<AnalyticsSummary[]> {
|
|
const {
|
|
metricType,
|
|
entityType,
|
|
entityId,
|
|
startDate,
|
|
endDate,
|
|
period,
|
|
} = queryDto;
|
|
|
|
const whereConditions: any = {
|
|
metricType,
|
|
period,
|
|
date: Between(new Date(startDate), new Date(endDate)),
|
|
};
|
|
|
|
if (entityType) {
|
|
whereConditions.entityType = entityType;
|
|
}
|
|
|
|
if (entityId) {
|
|
whereConditions.entityId = entityId;
|
|
}
|
|
|
|
return await this.analyticsSummaryRepository.find({
|
|
where: whereConditions,
|
|
order: { date: 'ASC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 查询原始数据
|
|
*/
|
|
private async queryRawData(queryDto: AnalyticsQueryDto): Promise<any> {
|
|
const {
|
|
metricType,
|
|
entityType,
|
|
entityId,
|
|
startDate,
|
|
endDate,
|
|
groupBy,
|
|
aggregation,
|
|
limit,
|
|
} = queryDto;
|
|
|
|
const queryBuilder = this.analyticsRecordRepository.createQueryBuilder('record');
|
|
|
|
// 基础条件
|
|
queryBuilder
|
|
.where('record.eventName = :metricType', { metricType })
|
|
.andWhere('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
});
|
|
|
|
if (entityType) {
|
|
queryBuilder.andWhere('record.entityType = :entityType', { entityType });
|
|
}
|
|
|
|
if (entityId) {
|
|
queryBuilder.andWhere('record.entityId = :entityId', { entityId });
|
|
}
|
|
|
|
// 分组和聚合
|
|
if (groupBy && groupBy.length > 0) {
|
|
const groupFields = groupBy.map(field => `record.${field}`);
|
|
queryBuilder.groupBy(groupFields.join(', '));
|
|
|
|
groupFields.forEach(field => {
|
|
queryBuilder.addSelect(field);
|
|
});
|
|
|
|
// 聚合函数
|
|
switch (aggregation) {
|
|
case 'count':
|
|
queryBuilder.addSelect('COUNT(*)', 'value');
|
|
break;
|
|
case 'sum':
|
|
queryBuilder.addSelect('SUM(record.value)', 'value');
|
|
break;
|
|
case 'avg':
|
|
queryBuilder.addSelect('AVG(record.value)', 'value');
|
|
break;
|
|
case 'min':
|
|
queryBuilder.addSelect('MIN(record.value)', 'value');
|
|
break;
|
|
case 'max':
|
|
queryBuilder.addSelect('MAX(record.value)', 'value');
|
|
break;
|
|
}
|
|
} else {
|
|
// 时间序列聚合
|
|
queryBuilder
|
|
.select('DATE(record.timestamp)', 'date')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.addSelect('AVG(record.value)', 'avgValue')
|
|
.addSelect('SUM(record.value)', 'sumValue')
|
|
.groupBy('DATE(record.timestamp)')
|
|
.orderBy('date', 'ASC');
|
|
}
|
|
|
|
queryBuilder.limit(limit);
|
|
|
|
return await queryBuilder.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 格式化汇总数据
|
|
*/
|
|
private formatSummaryData(summaries: AnalyticsSummary[], period: string): any {
|
|
return summaries.map(summary => ({
|
|
date: summary.date,
|
|
period,
|
|
totalCount: summary.totalCount,
|
|
successCount: summary.successCount,
|
|
failureCount: summary.failureCount,
|
|
totalValue: summary.totalValue,
|
|
averageValue: summary.averageValue,
|
|
minValue: summary.minValue,
|
|
maxValue: summary.maxValue,
|
|
metrics: summary.metrics,
|
|
dimensions: summary.dimensions,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 获取实时指标
|
|
*/
|
|
async getRealtimeMetrics(): Promise<any> {
|
|
const now = new Date();
|
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
|
|
const [
|
|
lastHourEvents,
|
|
lastDayEvents,
|
|
topEvents,
|
|
errorEvents,
|
|
] = await Promise.all([
|
|
this.analyticsRecordRepository.count({
|
|
where: { timestamp: MoreThanOrEqual(oneHourAgo) },
|
|
}),
|
|
this.analyticsRecordRepository.count({
|
|
where: { timestamp: MoreThanOrEqual(oneDayAgo) },
|
|
}),
|
|
this.getTopEvents(10),
|
|
this.analyticsRecordRepository.count({
|
|
where: {
|
|
eventType: 'error_event',
|
|
timestamp: MoreThanOrEqual(oneDayAgo),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
lastHourEvents,
|
|
lastDayEvents,
|
|
topEvents,
|
|
errorEvents,
|
|
timestamp: now,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 获取热门事件
|
|
*/
|
|
async getTopEvents(limit: number = 10): Promise<any[]> {
|
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
|
|
return await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('record.eventName', 'eventName')
|
|
.addSelect('record.eventType', 'eventType')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.where('record.timestamp >= :oneDayAgo', { oneDayAgo })
|
|
.groupBy('record.eventName, record.eventType')
|
|
.orderBy('count', 'DESC')
|
|
.limit(limit)
|
|
.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 获取用户活动分析
|
|
*/
|
|
async getUserActivityAnalytics(
|
|
startDate: string,
|
|
endDate: string,
|
|
userId?: number
|
|
): Promise<any> {
|
|
const queryBuilder = this.analyticsRecordRepository.createQueryBuilder('record');
|
|
|
|
queryBuilder
|
|
.select('DATE(record.timestamp)', 'date')
|
|
.addSelect('COUNT(DISTINCT record.userId)', 'activeUsers')
|
|
.addSelect('COUNT(*)', 'totalEvents')
|
|
.addSelect('record.eventType', 'eventType')
|
|
.where('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
})
|
|
.andWhere('record.eventType = :eventType', { eventType: 'user_action' });
|
|
|
|
if (userId) {
|
|
queryBuilder.andWhere('record.userId = :userId', { userId });
|
|
}
|
|
|
|
queryBuilder
|
|
.groupBy('DATE(record.timestamp), record.eventType')
|
|
.orderBy('date', 'ASC');
|
|
|
|
return await queryBuilder.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 获取性能指标分析
|
|
*/
|
|
async getPerformanceAnalytics(
|
|
startDate: string,
|
|
endDate: string,
|
|
metricName?: string
|
|
): Promise<any> {
|
|
const queryBuilder = this.analyticsRecordRepository.createQueryBuilder('record');
|
|
|
|
queryBuilder
|
|
.select('DATE(record.timestamp)', 'date')
|
|
.addSelect('record.eventName', 'metricName')
|
|
.addSelect('AVG(record.value)', 'averageValue')
|
|
.addSelect('MIN(record.value)', 'minValue')
|
|
.addSelect('MAX(record.value)', 'maxValue')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.where('record.eventType = :eventType', { eventType: 'performance_metric' })
|
|
.andWhere('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
})
|
|
.andWhere('record.value IS NOT NULL');
|
|
|
|
if (metricName) {
|
|
queryBuilder.andWhere('record.eventName = :metricName', { metricName });
|
|
}
|
|
|
|
queryBuilder
|
|
.groupBy('DATE(record.timestamp), record.eventName')
|
|
.orderBy('date', 'ASC');
|
|
|
|
return await queryBuilder.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 获取错误分析
|
|
*/
|
|
async getErrorAnalytics(
|
|
startDate: string,
|
|
endDate: string
|
|
): Promise<any> {
|
|
const [
|
|
errorTrends,
|
|
errorTypes,
|
|
topErrors,
|
|
] = await Promise.all([
|
|
this.getErrorTrends(startDate, endDate),
|
|
this.getErrorTypes(startDate, endDate),
|
|
this.getTopErrors(startDate, endDate),
|
|
]);
|
|
|
|
return {
|
|
trends: errorTrends,
|
|
types: errorTypes,
|
|
topErrors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 获取错误趋势
|
|
*/
|
|
private async getErrorTrends(startDate: string, endDate: string): Promise<any[]> {
|
|
return await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('DATE(record.timestamp)', 'date')
|
|
.addSelect('COUNT(*)', 'errorCount')
|
|
.where('record.eventType = :eventType', { eventType: 'error_event' })
|
|
.andWhere('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
})
|
|
.groupBy('DATE(record.timestamp)')
|
|
.orderBy('date', 'ASC')
|
|
.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 获取错误类型分布
|
|
*/
|
|
private async getErrorTypes(startDate: string, endDate: string): Promise<any[]> {
|
|
return await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('record.eventName', 'errorType')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.where('record.eventType = :eventType', { eventType: 'error_event' })
|
|
.andWhere('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
})
|
|
.groupBy('record.eventName')
|
|
.orderBy('count', 'DESC')
|
|
.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 获取热门错误
|
|
*/
|
|
private async getTopErrors(startDate: string, endDate: string): Promise<any[]> {
|
|
return await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('record.eventName', 'errorName')
|
|
.addSelect('record.eventData', 'errorData')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.addSelect('MAX(record.timestamp)', 'lastOccurrence')
|
|
.where('record.eventType = :eventType', { eventType: 'error_event' })
|
|
.andWhere('record.timestamp BETWEEN :startDate AND :endDate', {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
})
|
|
.groupBy('record.eventName, record.eventData')
|
|
.orderBy('count', 'DESC')
|
|
.limit(20)
|
|
.getRawMany();
|
|
}
|
|
|
|
/**
|
|
* 定时任务:汇总小时数据
|
|
*/
|
|
@Cron(CronExpression.EVERY_HOUR)
|
|
async summarizeHourlyData(): Promise<void> {
|
|
const now = new Date();
|
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
|
|
this.logger.log('开始汇总小时数据');
|
|
|
|
try {
|
|
await this.generateSummaries('hour', oneHourAgo, now);
|
|
this.logger.log('小时数据汇总完成');
|
|
} catch (error) {
|
|
this.logger.error(`小时数据汇总失败: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 定时任务:汇总日数据
|
|
*/
|
|
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
|
async summarizeDailyData(): Promise<void> {
|
|
const now = new Date();
|
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
|
|
this.logger.log('开始汇总日数据');
|
|
|
|
try {
|
|
await this.generateSummaries('day', yesterday, now);
|
|
this.logger.log('日数据汇总完成');
|
|
} catch (error) {
|
|
this.logger.error(`日数据汇总失败: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 生成汇总数据
|
|
*/
|
|
private async generateSummaries(
|
|
period: string,
|
|
startTime: Date,
|
|
endTime: Date
|
|
): Promise<void> {
|
|
// 获取需要汇总的指标类型
|
|
const metricTypes = await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('DISTINCT record.eventName', 'eventName')
|
|
.addSelect('record.eventType', 'eventType')
|
|
.addSelect('record.entityType', 'entityType')
|
|
.where('record.timestamp BETWEEN :startTime AND :endTime', {
|
|
startTime,
|
|
endTime,
|
|
})
|
|
.getRawMany();
|
|
|
|
for (const metric of metricTypes) {
|
|
await this.generateSummaryForMetric(
|
|
period,
|
|
metric.eventName,
|
|
metric.eventType,
|
|
metric.entityType,
|
|
startTime,
|
|
endTime
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 为特定指标生成汇总
|
|
*/
|
|
private async generateSummaryForMetric(
|
|
period: string,
|
|
eventName: string,
|
|
eventType: string,
|
|
entityType: string,
|
|
startTime: Date,
|
|
endTime: Date
|
|
): Promise<void> {
|
|
const date = new Date(startTime.getFullYear(), startTime.getMonth(), startTime.getDate());
|
|
|
|
// 计算汇总数据
|
|
const summaryData = await this.analyticsRecordRepository
|
|
.createQueryBuilder('record')
|
|
.select('COUNT(*)', 'totalCount')
|
|
.addSelect('SUM(CASE WHEN record.eventData->>"$.success" = "true" THEN 1 ELSE 0 END)', 'successCount')
|
|
.addSelect('SUM(CASE WHEN record.eventData->>"$.success" = "false" THEN 1 ELSE 0 END)', 'failureCount')
|
|
.addSelect('SUM(record.value)', 'totalValue')
|
|
.addSelect('AVG(record.value)', 'averageValue')
|
|
.addSelect('MIN(record.value)', 'minValue')
|
|
.addSelect('MAX(record.value)', 'maxValue')
|
|
.where('record.eventName = :eventName', { eventName })
|
|
.andWhere('record.eventType = :eventType', { eventType })
|
|
.andWhere('record.entityType = :entityType', { entityType })
|
|
.andWhere('record.timestamp BETWEEN :startTime AND :endTime', {
|
|
startTime,
|
|
endTime,
|
|
})
|
|
.getRawOne();
|
|
|
|
if (summaryData.totalCount > 0) {
|
|
// 保存或更新汇总数据
|
|
await this.analyticsSummaryRepository.upsert({
|
|
metricType: eventName,
|
|
entityType: entityType || 'global',
|
|
period,
|
|
date,
|
|
totalCount: parseInt(summaryData.totalCount),
|
|
successCount: parseInt(summaryData.successCount) || 0,
|
|
failureCount: parseInt(summaryData.failureCount) || 0,
|
|
totalValue: parseFloat(summaryData.totalValue) || 0,
|
|
averageValue: parseFloat(summaryData.averageValue) || null,
|
|
minValue: parseFloat(summaryData.minValue) || null,
|
|
maxValue: parseFloat(summaryData.maxValue) || null,
|
|
}, ['metricType', 'entityType', 'period', 'date']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理过期数据
|
|
*/
|
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
|
async cleanupOldData(): Promise<void> {
|
|
const retentionDays = parseInt(process.env.ANALYTICS_RETENTION_DAYS) || 90;
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
|
|
this.logger.log(`开始清理 ${retentionDays} 天前的分析数据`);
|
|
|
|
try {
|
|
const result = await this.analyticsRecordRepository
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.where('timestamp < :cutoffDate', { cutoffDate })
|
|
.execute();
|
|
|
|
this.logger.log(`清理完成,删除了 ${result.affected} 条记录`);
|
|
} catch (error) {
|
|
this.logger.error(`数据清理失败: ${error.message}`);
|
|
}
|
|
}
|
|
} |