博客数据统计系统重构:增加IP防刷和GA4 事件追踪实践

2026年02月09日6 次阅读0 人喜欢
Next.jsGA4Google Analytics数据统计防刷RedisTypeScript数据库设计
所属合集

最近在完善博客的数据统计功能,主要是两个问题:

  1. 点赞数据直接存在文章表里,前端只有简单的防重复,刷新页面就能绕过
  2. 想接入 Google Analytics 4 来追踪用户行为,但不知道怎么搞

这篇文章记录一下完整的实现过程,还有一些踩坑点。

问题背景

原来的点赞系统是这样的:点击按钮 -> 前端发个请求 -> 后端 likes + 1 -> 返回成功。前端会记录个 localStorage 来防止重复点击,但这个太容易绕过了。

而且我想做数据分析和用户行为追踪,Google Analytics 4 是个不错的选择。但是看了下文档,GA4 有两套追踪方式:

  • 客户端 gtag.js:网页浏览、用户交互
  • 服务端 Measurement Protocol:后端事件,比如点赞

我的需求是:

  • 点赞要防刷,但不需要太严格(博客场景)
  • GA4 追踪全量事件(浏览、点赞、评论、收藏)
  • 配置存在数据库里,通过管理后台改,不要写死在环境变量

技术方案

防刷方案:基于 IP 的宽松限制

用户确认过,不需要登录才能点赞,所以用 IP 限制就行:

  • 同一个 IP,24 小时内只能点赞一次
  • Redis 缓存 + 数据库兜底
  • 虽然可以换 IP 绕过,但博客场景够用了

GA4 集成:服务端 Measurement Protocol

客户端的 gtag.js 已经在 layout 里集成了,这个负责页面浏览追踪。

服务端的点赞事件用 Measurement Protocol v2 发送。配置从数据库的 TbConfig 表读取,参考了项目中 vector-db-config.ts 的模式。

数据库设计

新增一张 TbLikeRecord 表记录点赞行为:

prisma 复制代码
model TbLikeRecord {
  id          Int      @id @default(autoincrement())
  target_type String   @db.VarChar(50)  // POST, COLLECTION, COMMENT
  target_id   Int
  ip_address  String   @db.VarChar(45)   // IPv4/IPv6
  created_at  DateTime @default(now())

  @@index([target_type, target_id, ip_address], name: "idx_target_ip")
  @@index([created_at], name: "idx_created_at")
  @@map("tb_like_record")
}

原来的 TbPost.likesTbCollection.total_likes 字段保留,作为冗余计数字段。

核心实现

IP 获取工具

src/lib/ip.ts - 从各种代理头部提取真实 IP:

typescript 复制代码
export function getClientIp(request: NextRequest): string {
  const xForwardedFor = request.headers.get('x-forwarded-for');
  if (xForwardedFor) return xForwardedFor.split(',')[0]?.trim() || '127.0.0.1';

  const xRealIp = request.headers.get('x-real-ip');
  if (xRealIp) return xRealIp;

  const cfConnectingIp = request.headers.get('cf-connecting-ip');
  if (cfConnectingIp) return cfConnectingIp;

  return '127.0.0.1';
}

支持 Nginx、Cloudflare 等常见代理环境。

点赞记录服务

src/services/like-record.ts - 核心防刷逻辑:

typescript 复制代码
export async function canLike(targetType: string, targetId: number, request: NextRequest): Promise<boolean> {
  const ip = getClientIp(request);
  const cacheKey = `like:${targetType}:${targetId}:${ip}`;
  
  // 先查 Redis
  const cached = await redis.get(cacheKey);
  if (cached) return false;
  
  // 兜底查数据库
  const hours = await getLikeIpLimitHours();
  const timeLimit = new Date(Date.now() - hours * 60 * 60 * 1000);
  
  const existing = await prisma.tbLikeRecord.findFirst({
    where: {
      target_type: targetType,
      target_id: targetId,
      ip_address: ip,
      created_at: { gte: timeLimit }
    }
  });
  
  return !existing;
}

export async function recordLike(targetType: string, targetId: number, request: NextRequest) {
  const ip = getClientIp(request);
  const hours = await getLikeIpLimitHours();
  
  await prisma.tbLikeRecord.create({
    data: { target_type: targetType, target_id: targetId, ip_address: ip }
  });
  
  await redis.setex(`like:${targetType}:${targetId}:${ip}`, hours * 3600, '1');
}

三层缓存:Redis -> 数据库 -> 写入时同步更新两层。

GA4 配置读取

src/lib/analytics-config.ts - 从数据库读取配置,带缓存:

typescript 复制代码
const configCache = new Map<string, { value: string | null; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟

async function getConfigValue(key: string, defaultValue?: string): Promise<string> {
  const cached = configCache.get(key);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.value ?? defaultValue ?? '';
  }

  const configs = await configByKeys([key]);
  const value = configs[key]?.value ?? null;

  configCache.set(key, { value, timestamp: Date.now() });
  return value ?? defaultValue ?? '';
}

export async function getAnalyticsConfig(): Promise<AnalyticsConfig> {
  const measurementId = await getConfigValue('ga4.measurement_id');
  const apiSecret = await getConfigValue('ga4.api_secret');
  return { measurementId, apiSecret };
}

GA4 事件发送

src/lib/analytics.ts - 发送事件到 Measurement Protocol:

typescript 复制代码
export async function trackEvent(
  eventName: string,
  params: Record<string, string | number | boolean | undefined>,
  request: NextRequest
): Promise<TrackEventResult> {
  const config = await getAnalyticsConfig();
  if (!config.measurementId || !config.apiSecret) {
    return { sessionId: '', needsSetCookie: false };
  }

  const [sessionId, needsSetCookie] = extractSessionId(request);

  const payload = {
    client_id: sessionId,
    events: [{
      name: eventName,
      params: {
        ...params,
        engagement_time_msec: 100,
        session_id: sessionId,
      }
    }]
  };

  const url = `https://www.google-analytics.com/mp/collect?measurement_id=${config.measurementId}&api_secret=${config.apiSecret}`;

  await proxyFetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  return { sessionId, needsSetCookie };
}

这里有个关键点:Session ID 管理。GA4 需要 client_idsession_id 来识别用户会话,我们用 Cookie 持久化。

API 路由改造

文章点赞 API src/app/api/post/fav/route.ts

typescript 复制代码
if (type === 'likes') {
  // IP 防刷检查
  const canLikeThis = await canLike('POST', postId, request);
  if (!canLikeThis) {
    return NextResponse.json(errorResponse('您已经点过赞了'), { status: 429 });
  }

  // 记录点赞
  await recordLike('POST', postId, request);
}

// 更新统计
const updatedPost = await prisma.tbPost.update({
  where: { id: postId },
  data: { [type]: currentValue + 1 }
});

// 发送 GA4 事件
if (type === 'likes') {
  const gaResult = await trackEvent('like', {
    target_type: 'POST',
    target_id: postId,
    post_title: post.title || undefined,
  }, request);

  const response = NextResponse.json(successResponse(updatedPost));
  if (gaResult.needsSetCookie) {
    response.headers.set('Set-Cookie', createSessionCookie(gaResult.sessionId));
  }
  return response;
}

合集点赞的改造类似,不展开了。

踩坑记录

1. TypeScript 类型错误

一开始直接传 post.title,结果 TypeScript 报错:

复制代码
Type 'string | null' is not assignable to type 'string | number | boolean | undefined'

改成 post.title || undefined 就好了。

2. React Hydration 不匹配

HeaderUserMenu.tsx 里的 className 写了多行,导致 SSR 和 CSR 渲染不一致:

tsx 复制代码
// ❌ 错误写法
className="flex items-center gap-2 px-4 py-1.5 rounded-full
  bg-slate-900 dark:bg-white
  text-white dark:text-slate-900..."

// ✅ 正确写法
className="flex items-center gap-2 px-4 py-1.5 rounded-full bg-slate-900 dark:bg-white text-white dark:text-slate-900..."

改成单行就好了。

3. GA4 连接超时

国内环境访问 Google Analytics 会超时:

复制代码
❌ GA4 事件发送异常: TypeError: fetch failed
Connect Timeout Error

项目里已经有 proxyFetch 封装,用 undici 的 ProxyAgent,替换一下就好了:

typescript 复制代码
// 之前
const response = await fetch(url, {...});

// 之后
const response = await proxyFetch(url, {...});

需要在 .env 里配置代理:

复制代码
HTTPS_PROXY=http://127.0.0.1:7890

4. GA4 Payload 格式问题

这是最坑的,日志显示发送成功,但 GA4 后台看不到事件。

查了官方文档后发现几个问题:

问题 1:session_id 格式错误

一开始 Cookie 里没有 Session ID,直接返回 'unknown_session',但 GA4 要求 session_id 必须是数字。

改成时间戳:

typescript 复制代码
return Math.floor(Date.now() / 1000).toString();

问题 2:不支持的字段

原来的 payload 包含了 user_agentip_address,但 Measurement Protocol v2 不支持这些字段。

正确格式:

typescript 复制代码
const payload = {
  client_id: sessionId,
  // 不要 user_agent
  events: [{
    name: eventName,
    params: {
      ...params,
      engagement_time_msec: 100,
      session_id: sessionId,
      // 不要 ip_address
    }
  }]
};

问题 3:Session Cookie 未设置

最开始只读 Cookie 不设置,导致每次都是新 Session。

改成返回是否需要设置 Cookie,API 路由里判断并设置:

typescript 复制代码
export function createSessionCookie(sessionId: string): string {
  const maxAge = 2 * 365 * 24 * 60 * 60; // 2 年
  return `ga_session_id=${sessionId}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`;
}

5. GA4 事件延迟

服务端 Measurement Protocol 发送的事件,不会立即显示在 GA4 后台

  • DebugView:只能看客户端 gtag 事件
  • Realtime:有时延迟 5-10 分钟
  • 标准报告:需要等待 24-48 小时

所以开发时只能看服务器日志确认发送成功:

复制代码
✅ GA4 事件发送成功: like { target_type: 'POST', target_id: 144, post_title: '...' }

配置管理

在管理后台 /c/config 添加以下配置项:

Key 标题 示例值 说明
ga4.measurement_id GA4 Measurement ID G-2SG5L56YHQ GA4 测量 ID
ga4.api_secret GA4 API Secret your-secret 服务端事件 API 密钥
like.ip_limit_hours 点赞 IP 限制时间 24 同一 IP 点赞限制时长(小时)

api_secret 需要在 GA4 后台创建:Data Streams > Create events > Measurement Protocol

总结

这次重构实现了:

  • IP 防刷(24 小时限制 + Redis 缓存)
  • GA4 全量事件追踪
  • 数据库配置管理(无需改环境变量)
  • 代理支持(国内环境可访问)

还有一些可以优化的点:

  • TbLikeRecord 表会持续增长,需要定期清理过期记录
  • 可以考虑 MySQL Event 或应用层定时任务清理
  • 点赞状态查询 API 可以进一步优化缓存

关于 GA4,它不能作为点赞系统的主数据源。GA4 是分析工具,有延迟和采样,正确做法是数据库存储业务数据 + GA4 追踪分析事件。

希望这篇文章对大家有帮助!

加载评论中...