Skip to content

VitePress 站点统计系统设计与实现

在现代技术文档和博客网站中,了解用户行为和站点访问情况对于内容优化和用户体验改进至关重要。本文将介绍如何使用 NestJS 构建一个完整的 VitePress 站点统计系统,包括数据收集、存储、分析和展示。

一、系统架构设计

1.1 整体架构

┌─────────────────┐    HTTP    ┌────────────────┐    REST API    ┌─────────────────┐
│   VitePress     ├───────────►│  数据收集服务  ├───────────────►│   统计数据库    │
│   站点          │            │  (NestJS)      │                │   (MySQL)       │
└─────────────────┘            └────────────────┘                └─────────────────┘
                                      │                                 │
                                      │ 分析任务                         │
                                      ▼                                 ▼
                              ┌────────────────┐                ┌─────────────────┐
                              │  数据分析服务  │◄───────────────┤   定时任务      │
                              │  (NestJS)      │                │   (Cron)        │
                              └────────────────┘                └─────────────────┘

                                      │ 查询接口

                              ┌────────────────┐
                              │  数据展示API   │◄───────────────┐
                              │  (NestJS)      │                │
                              └────────────────┘                │
                                      │                         │
                                      ▼                         │
                              ┌────────────────┐      HTTP    │
                              │  前端展示页面  ├───────────────┘
                              │  (Vue3/Vite)   │
                              └────────────────┘

1.2 技术栈选择

  • 后端框架: NestJS
  • 数据库: MySQL (使用 @nestjs/typeorm)
  • ORM: TypeORM
  • 消息队列: Redis (可选,用于高并发场景)
  • 前端展示: Vue3 + Vite
  • 部署: Docker + Nginx

二、核心功能实现

2.1 数据模型设计

typescript
// src/analytics/entities/page-view.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm';

@Entity('page_views')
@Index(['url', 'timestamp'])
@Index(['ip', 'timestamp'])
export class PageView {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 2000 })
  url: string;

  @Column({ type: 'text' })
  userAgent: string;

  @Column({ type: 'varchar', length: 45 })
  ip: string;

  @Column({ type: 'varchar', length: 2000, nullable: true })
  referer: string;

  @Column({ type: 'varchar', length: 20, nullable: true })
  language: string;

  @Column({ type: 'int', nullable: true })
  screenWidth: number;

  @Column({ type: 'int', nullable: true })
  screenHeight: number;

  @Column({ type: 'int', nullable: true })
  visitDuration: number;

  @CreateDateColumn()
  timestamp: Date;
}
typescript
// src/analytics/entities/user-session.entity.ts
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('user_sessions')
export class UserSession {
  @PrimaryColumn({ type: 'varchar', length: 32 })
  sessionId: string;

  @Column({ type: 'varchar', length: 45 })
  ip: string;

  @Column({ type: 'text' })
  userAgent: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  lastActivity: Date;

  @Column({ type: 'text', nullable: true })
  visitedPages: string;
}

2.2 数据收集模块

typescript
// src/analytics/analytics.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PageView } from './entities/page-view.entity';
import { UserSession } from './entities/user-session.entity';
import { AnalyticsService } from './analytics.service';
import { AnalyticsController } from './analytics.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([PageView, UserSession]),
  ],
  controllers: [AnalyticsController],
  providers: [AnalyticsService],
  exports: [AnalyticsService],
})
export class AnalyticsModule {}
typescript
// src/analytics/analytics.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, Like } from 'typeorm';
import { PageView } from './entities/page-view.entity';
import { UserSession } from './entities/user-session.entity';

@Injectable()
export class AnalyticsService {
  constructor(
    @InjectRepository(PageView)
    private pageViewRepository: Repository<PageView>,
    @InjectRepository(UserSession)
    private userSessionRepository: Repository<UserSession>,
  ) {}

  async trackPageView(pageViewData: Partial<PageView>): Promise<PageView> {
    const pageView = this.pageViewRepository.create(pageViewData);
    return this.pageViewRepository.save(pageView);
  }

  async createOrUpdateSession(sessionData: {
    sessionId: string;
    ip: string;
    userAgent: string;
    url: string;
  }): Promise<UserSession> {
    let session = await this.userSessionRepository.findOne({
      where: { sessionId: sessionData.sessionId },
    });

    if (session) {
      // 更新现有会话
      session.lastActivity = new Date();
      const visitedPages = session.visitedPages ? JSON.parse(session.visitedPages) : [];
      if (!visitedPages.includes(sessionData.url)) {
        visitedPages.push(sessionData.url);
        session.visitedPages = JSON.stringify(visitedPages);
      }
      return this.userSessionRepository.save(session);
    } else {
      // 创建新会话
      session = this.userSessionRepository.create({
        sessionId: sessionData.sessionId,
        ip: sessionData.ip,
        userAgent: sessionData.userAgent,
        createdAt: new Date(),
        lastActivity: new Date(),
        visitedPages: JSON.stringify([sessionData.url]),
      });
      return this.userSessionRepository.save(session);
    }
  }

  async getPageViews(
    startDate?: Date,
    endDate?: Date,
    url?: string,
  ): Promise<PageView[]> {
    const where: any = {};
    
    if (startDate || endDate) {
      where.timestamp = Between(
        startDate || new Date(0),
        endDate || new Date()
      );
    }
    
    if (url) {
      where.url = url;
    }

    return this.pageViewRepository.find({
      where,
      order: { timestamp: 'DESC' },
    });
  }

  async getPopularPages(
    limit: number = 10,
    startDate?: Date,
    endDate?: Date,
  ): Promise<{ url: string; count: number }[]> {
    const query = this.pageViewRepository.createQueryBuilder('page_view');
    
    if (startDate || endDate) {
      query.where('page_view.timestamp BETWEEN :startDate AND :endDate', {
        startDate: startDate || new Date(0),
        endDate: endDate || new Date(),
      });
    }

    return query
      .select('page_view.url', 'url')
      .addSelect('COUNT(page_view.id)', 'count')
      .groupBy('page_view.url')
      .orderBy('count', 'DESC')
      .limit(limit)
      .getRawMany();
  }

  async getUniqueVisitors(
    startDate?: Date,
    endDate?: Date,
  ): Promise<number> {
    const query = this.userSessionRepository.createQueryBuilder('user_session');
    
    if (startDate || endDate) {
      query.where('user_session.createdAt BETWEEN :startDate AND :endDate', {
        startDate: startDate || new Date(0),
        endDate: endDate || new Date(),
      });
    }

    return query.getCount();
  }
}

2.3 数据收集接口

typescript
// src/analytics/analytics.controller.ts
import {
  Controller,
  Post,
  Body,
  Get,
  Query,
  Ip,
  Headers,
  BadRequestException,
} from '@nestjs/common';
import { AnalyticsService } from './analytics.service';

class TrackPageViewDto {
  url: string;
  referer?: string;
  language?: string;
  screenWidth?: number;
  screenHeight?: number;
  visitDuration?: number;
}

@Controller('analytics')
export class AnalyticsController {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Post('track')
  async trackPageView(
    @Body() trackData: TrackPageViewDto,
    @Ip() ip: string,
    @Headers('user-agent') userAgent: string,
    @Headers('referer') referer: string,
  ) {    
    // 验证必要字段
    if (!trackData.url) {
      throw new BadRequestException('URL is required');
    }

    // 生成会话ID(在实际应用中可能从cookie获取)
    const sessionId = this.generateSessionId(ip, userAgent);

    // 记录页面访问
    await this.analyticsService.trackPageView({
      url: trackData.url,
      userAgent: userAgent,
      ip: ip,
      referer: referer || trackData.referer,
      language: trackData.language,
      screenWidth: trackData.screenWidth,
      screenHeight: trackData.screenHeight,
      visitDuration: trackData.visitDuration,
      timestamp: new Date(),
    });

    // 更新或创建用户会话
    await this.analyticsService.createOrUpdateSession({
      sessionId,
      ip,
      userAgent,
      url: trackData.url,
    });

    return { success: true, message: 'Page view tracked successfully' };
  }

  @Get('popular-pages')
  async getPopularPages(
    @Query('limit') limit: string = '10',
    @Query('startDate') startDate?: string,
    @Query('endDate') endDate?: string,
  ) {
    const limitNum = parseInt(limit, 10);
    const start = startDate ? new Date(startDate) : undefined;
    const end = endDate ? new Date(endDate) : undefined;

    return this.analyticsService.getPopularPages(limitNum, start, end);
  }

  @Get('unique-visitors')
  async getUniqueVisitors(
    @Query('startDate') startDate?: string,
    @Query('endDate') endDate?: string,
  ) {
    const start = startDate ? new Date(startDate) : undefined;
    const end = endDate ? new Date(endDate) : undefined;

    return { count: await this.analyticsService.getUniqueVisitors(start, end) };
  }

  private generateSessionId(ip: string, userAgent: string): string {
    // 简单的会话ID生成方法
    // 在生产环境中应使用更安全的方法
    return require('crypto')
      .createHash('md5')
      .update(ip + userAgent + new Date().toDateString())
      .digest('hex');
  }
}

2.4 VitePress 集成代码

在 VitePress 站点中添加统计代码:

javascript
// .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'

export default {
  ...DefaultTheme,
  enhanceApp({ app, router, siteData }) {
    // 页面切换时发送统计数据
    router.onAfterRouteChanged = (to, from) => {
      // 发送页面访问数据到统计服务
      if (typeof window !== 'undefined') {
        // 获取用户信息
        const data = {
          url: window.location.href,
          referer: document.referrer,
          language: navigator.language,
          screenWidth: window.screen.width,
          screenHeight: window.screen.height,
          timestamp: new Date().toISOString()
        };

        // 发送到后端统计服务
        fetch('/api/analytics/track', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(data),
        }).catch(err => {
          console.error('统计信息发送失败:', err);
        });
      }
    };
  }
}

三、数据库迁移

3.1 创建迁移文件

bash
# 生成迁移文件
npm run typeorm migration:create src/migrations/CreateAnalyticsTables

3.2 编写迁移脚本

typescript
// src/migrations/1623456789456-CreateAnalyticsTables.ts
import { MigrationInterface, QueryRunner } from "typeorm";

export class CreateAnalyticsTables1623456789456 implements MigrationInterface {
    name = 'CreateAnalyticsTables1623456789456'

    public async up(queryRunner: QueryRunner): Promise<void> {
        // 创建 page_views 表
        await queryRunner.query(`
            CREATE TABLE \`page_views\` (
                \`id\` int NOT NULL AUTO_INCREMENT,
                \`url\` varchar(2000) NOT NULL,
                \`userAgent\` text NOT NULL,
                \`ip\` varchar(45) NOT NULL,
                \`referer\` varchar(2000) NULL,
                \`language\` varchar(20) NULL,
                \`screenWidth\` int NULL,
                \`screenHeight\` int NULL,
                \`visitDuration\` int NULL,
                \`timestamp\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
                PRIMARY KEY (\`id\`),
                INDEX \`IDX_page_views_url_timestamp\` (\`url\`, \`timestamp\`),
                INDEX \`IDX_page_views_ip_timestamp\` (\`ip\`, \`timestamp\`)
            ) ENGINE=InnoDB
        `);

        // 创建 user_sessions 表
        await queryRunner.query(`
            CREATE TABLE \`user_sessions\` (
                \`sessionId\` varchar(32) NOT NULL,
                \`ip\` varchar(45) NOT NULL,
                \`userAgent\` text NOT NULL,
                \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
                \`lastActivity\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
                \`visitedPages\` text NULL,
                PRIMARY KEY (\`sessionId\`)
            ) ENGINE=InnoDB
        `);
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`DROP INDEX \`IDX_page_views_ip_timestamp\` ON \`page_views\``);
        await queryRunner.query(`DROP INDEX \`IDX_page_views_url_timestamp\` ON \`page_views\``);
        await queryRunner.query(`DROP TABLE \`page_views\``);
        await queryRunner.query(`DROP TABLE \`user_sessions\``);
    }
}

3.3 执行迁移

bash
# 运行迁移
npm run typeorm migration:run

四、数据分析与报告

4.1 定时任务分析

typescript
// src/analytics/tasks/analytics.tasks.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { AnalyticsService } from '../analytics.service';

@Injectable()
export class AnalyticsTasks {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async generateDailyReport() {
    const today = new Date();
    const yesterday = new Date(today);
    yesterday.setDate(yesterday.getDate() - 1);

    // 生成昨日报告
    const popularPages = await this.analyticsService.getPopularPages(10, yesterday, today);
    const uniqueVisitors = await this.analyticsService.getUniqueVisitors(yesterday, today);

    console.log('每日统计报告:');
    console.log('热门页面:', popularPages);
    console.log('独立访客数:', uniqueVisitors);

    // 这里可以将报告保存到数据库或发送邮件
  }

  @Cron(CronExpression.EVERY_HOUR)
  async cleanExpiredSessions() {
    const oneHourAgo = new Date();
    oneHourAgo.setHours(oneHourAgo.getHours() - 1);

    // 清理过期会话(1小时无活动)
    // 实际实现需要根据业务需求调整
  }
}

4.2 报告接口

typescript
// src/analytics/reports.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';

@Controller('analytics/reports')
export class ReportsController {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Get('summary')
  async getSummaryReport(
    @Query('startDate') startDate?: string,
    @Query('endDate') endDate?: string,
  ) {
    const start = startDate ? new Date(startDate) : undefined;
    const end = endDate ? new Date(endDate) : undefined;

    const [popularPages, uniqueVisitors] = await Promise.all([
      this.analyticsService.getPopularPages(10, start, end),
      this.analyticsService.getUniqueVisitors(start, end),
    ]);

    return {
      popularPages,
      uniqueVisitors,
      dateRange: {
        start: startDate,
        end: endDate,
      },
    };
  }
}

五、性能优化与安全考虑

5.1 限流与防护

typescript
// src/analytics/guards/throttler.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';

@Injectable()
export class AnalyticsThrottlerGuard extends ThrottlerGuard {
  protected async shouldSkip(): Promise<boolean> {
    // 可以根据IP或其他条件跳过限流
    return false;
  }
}
typescript
// 在控制器中应用限流
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';

@Controller('analytics')
@UseGuards(AnalyticsThrottlerGuard)
@Throttle(10, 60) // 每分钟最多10次请求
export class AnalyticsController {
  // ... 控制器实现
}

5.2 数据验证与清洗

typescript
// src/analytics/pipes/analytics-validation.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class AnalyticsValidationPipe implements PipeTransform {
  transform(value: any) {
    // 验证和清洗数据
    if (value.url) {
      // 验证URL格式
      try {
        new URL(value.url);
      } catch {
        throw new BadRequestException('Invalid URL format');
      }
    }

    // 限制字符串长度
    if (value.userAgent && value.userAgent.length > 500) {
      value.userAgent = value.userAgent.substring(0, 500);
    }

    // 验证数字字段
    if (value.screenWidth) {
      value.screenWidth = parseInt(value.screenWidth, 10);
      if (isNaN(value.screenWidth) || value.screenWidth <= 0) {
        delete value.screenWidth;
      }
    }

    return value;
  }
}

六、部署与监控

6.1 Docker 配置

dockerfile
# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["node", "dist/main.js"]
yaml
# docker-compose.yml
version: '3.8'

services:
  analytics-api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_HOST=mysql
      - DATABASE_PORT=3306
      - DATABASE_USERNAME=root
      - DATABASE_PASSWORD=password
      - DATABASE_NAME=analytics
      - NODE_ENV=production
    depends_on:
      - mysql
    restart: unless-stopped

  mysql:
    image: mysql:8
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=analytics
    volumes:
      - mysql_data:/var/lib/mysql
    restart: unless-stopped

volumes:
  mysql_data:

6.2 TypeORM 配置

typescript
// ormconfig.ts
import { DataSource } from 'typeorm';

export default new DataSource({
  type: 'mysql',
  host: process.env.DATABASE_HOST || 'localhost',
  port: parseInt(process.env.DATABASE_PORT, 10) || 3306,
  username: process.env.DATABASE_USERNAME || 'root',
  password: process.env.DATABASE_PASSWORD || 'password',
  database: process.env.DATABASE_NAME || 'analytics',
  entities: [__dirname + '/src/**/*.entity{.ts,.js}'],
  migrations: [__dirname + '/src/migrations/*{.ts,.js}'],
  synchronize: false, // 生产环境必须为 false
  logging: process.env.NODE_ENV === 'development',
});

6.3 监控与日志

typescript
// src/analytics/interceptors/logging.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => {
          const request = context.switchToHttp().getRequest();
          console.log(
            `请求处理完成: ${request.method} ${request.url} - ${Date.now() - now}ms`,
          );
        }),
      );
  }
}

七、总结

本文介绍了如何使用 NestJS 和 MySQL 构建一个完整的 VitePress 站点统计系统,包括:

  1. 数据模型设计 - 使用 TypeORM 设计页面访问和用户会话实体
  2. 核心功能实现 - 实现数据收集、存储和查询功能
  3. 数据库迁移 - 使用 TypeORM 迁移系统管理数据库结构
  4. VitePress 集成 - 在前端站点中集成统计代码
  5. 数据分析 - 通过定时任务生成报告
  6. 性能优化 - 实现限流、数据验证等安全措施
  7. 部署方案 - 提供 Docker 部署配置和 TypeORM 配置

通过这个系统,您可以全面了解 VitePress 站点的访问情况,为内容优化和用户体验改进提供数据支持。在实际应用中,您还可以根据具体需求扩展更多功能,如用户行为分析、转化率跟踪等。