feat: implement comprehensive reading analytics system with bot detection
Features: - Add article view tracking with automatic deduplication (per user/IP) - Implement intelligent bot detection (35+ patterns: Google, Bing, Baidu, GPTBot, etc) - Create admin analytics dashboard with statistics and filtering - Display view count and comment count on article cards - Add search-based article filter (replaced dropdown for scalability) Analytics Dashboard: - Global stats: total views, human/bot views, unique IPs/users - Top 20 articles ranking with detailed metrics - Detailed view records with time, article, user, IP, user-agent - Filters: article title search, IP search, show/hide bots - Pagination support (50 records per page) Technical Implementation: - Async view recording (non-blocking) - Dual deduplication (application + database layer) - Database indexes for performance optimization - Batch query for comment counts - Full i18n support (Chinese/English) Files Added: - models/article_view.go: View tracking model - models/bot_detector.go: Bot detection logic - handlers/admin_analytics.go: Analytics page handler - templates/admin/analytics_views.html: Analytics UI - test_analytics.sh: Testing script - Documentation: implementation guide, usage guide, changelog Files Modified: - handlers/home.go: Add view recording and comment count queries - models/db.go: Add ArticleView to auto-migration - main.go: Add analytics routes - i18n/i18n.go: Add 35+ translation keys - templates/admin/dashboard.html: Add analytics entry link - templates/pages/home.html: Display view/comment counts on cards Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewAnalyticsPage renders the admin analytics page showing article view statistics.
|
||||
func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
|
||||
// Parse filters from query params
|
||||
articleTitle := c.Query("article_title")
|
||||
userIDStr := c.Query("user_id")
|
||||
ip := c.Query("ip")
|
||||
showBots := c.Query("show_bots") == "1"
|
||||
page := 1
|
||||
if p := c.Query("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Build query for view records
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Preload("Article").
|
||||
Preload("User").
|
||||
Order("created_at DESC")
|
||||
|
||||
// Filter by article title (join with articles table)
|
||||
if articleTitle != "" {
|
||||
query = query.Joins("JOIN articles ON article_views.article_id = articles.id").
|
||||
Where("articles.title LIKE ?", "%"+articleTitle+"%")
|
||||
}
|
||||
if userIDStr != "" {
|
||||
query = query.Where("user_id = ?", userIDStr)
|
||||
}
|
||||
if ip != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+ip+"%")
|
||||
}
|
||||
if !showBots {
|
||||
query = query.Where("is_bot = ?", false)
|
||||
}
|
||||
|
||||
// Pagination
|
||||
pageSize := 50
|
||||
offset := (page - 1) * pageSize
|
||||
var views []models.ArticleView
|
||||
var totalCount int64
|
||||
query.Count(&totalCount)
|
||||
query.Limit(pageSize).Offset(offset).Find(&views)
|
||||
|
||||
hasMore := int64(offset+len(views)) < totalCount
|
||||
|
||||
// Calculate global statistics
|
||||
var stats struct {
|
||||
TotalViews int64
|
||||
UniqueIPs int64
|
||||
UniqueUsers int64
|
||||
BotViews int64
|
||||
HumanViews int64
|
||||
}
|
||||
db.Model(&models.ArticleView{}).Count(&stats.TotalViews)
|
||||
db.Model(&models.ArticleView{}).Where("is_bot = ?", true).Count(&stats.BotViews)
|
||||
db.Model(&models.ArticleView{}).Where("is_bot = ?", false).Count(&stats.HumanViews)
|
||||
db.Model(&models.ArticleView{}).Distinct("ip").Count(&stats.UniqueIPs)
|
||||
db.Model(&models.ArticleView{}).Where("user_id IS NOT NULL").Distinct("user_id").Count(&stats.UniqueUsers)
|
||||
|
||||
// Per-article statistics
|
||||
type ArticleStat struct {
|
||||
ArticleID uint
|
||||
Title string
|
||||
ViewCount int64
|
||||
UniqueIPs int64
|
||||
BotViews int64
|
||||
HumanViews int64
|
||||
}
|
||||
var articleStats []ArticleStat
|
||||
|
||||
db.Raw(`
|
||||
SELECT
|
||||
av.article_id,
|
||||
a.title,
|
||||
COUNT(*) as view_count,
|
||||
COUNT(DISTINCT av.ip) as unique_ips,
|
||||
SUM(CASE WHEN av.is_bot THEN 1 ELSE 0 END) as bot_views,
|
||||
SUM(CASE WHEN NOT av.is_bot THEN 1 ELSE 0 END) as human_views
|
||||
FROM article_views av
|
||||
LEFT JOIN articles a ON av.article_id = a.id
|
||||
WHERE a.deleted_at IS NULL
|
||||
GROUP BY av.article_id, a.title
|
||||
ORDER BY view_count DESC
|
||||
LIMIT 20
|
||||
`).Scan(&articleStats)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["analytics_views_title"]
|
||||
data["Views"] = views
|
||||
data["Stats"] = stats
|
||||
data["ArticleStats"] = articleStats
|
||||
data["Filters"] = gin.H{
|
||||
"ArticleTitle": articleTitle,
|
||||
"UserID": userIDStr,
|
||||
"IP": ip,
|
||||
"ShowBots": showBots,
|
||||
"Page": page,
|
||||
}
|
||||
data["Pagination"] = gin.H{
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"HasMore": hasMore,
|
||||
"Total": totalCount,
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "analytics_views", data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user