博客数据统计系统重构:增加IP防刷和GA4 事件追踪实践
最近在完善博客的数据统计功能,主要是两个问题:
- 点赞数据直接存在文章表里,前端只有简单的防重复,刷新页面就能绕过
- 想接入 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.likes 和 TbCollection.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_id 和 session_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_agent 和 ip_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 追踪分析事件。
希望这篇文章对大家有帮助!