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:
+138
-1
@@ -28,7 +28,36 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
Order(publishedArticleOrder).
|
||||
Limit(10).
|
||||
Find(&articles)
|
||||
|
||||
// Get comment counts for all articles
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
var commentCounts []CommentCount
|
||||
if len(articleIDs) > 0 {
|
||||
db.Model(&models.Comment{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("article_id IN ?", articleIDs).
|
||||
Where("status = ?", models.CommentApproved).
|
||||
Group("article_id").
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Add comment counts to template data
|
||||
data["Articles"] = articles
|
||||
data["CommentCounts"] = commentCountMap
|
||||
|
||||
c.HTML(http.StatusOK, "home", data)
|
||||
}
|
||||
@@ -60,8 +89,48 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
Where("status = ?", models.ArticlePublished).
|
||||
Count(&total)
|
||||
|
||||
// Get comment counts for these articles
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
var commentCounts []CommentCount
|
||||
if len(articleIDs) > 0 {
|
||||
db.Model(&models.Comment{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("article_id IN ?", articleIDs).
|
||||
Where("status = ?", models.CommentApproved).
|
||||
Group("article_id").
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Build response with comment counts
|
||||
type ArticleResponse struct {
|
||||
models.Article
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
}
|
||||
|
||||
articlesWithCounts := make([]ArticleResponse, len(articles))
|
||||
for i, article := range articles {
|
||||
articlesWithCounts[i] = ArticleResponse{
|
||||
Article: article,
|
||||
CommentCount: commentCountMap[article.ID],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"articles": articles,
|
||||
"articles": articlesWithCounts,
|
||||
"hasMore": int64(offset+len(articles)) < total,
|
||||
})
|
||||
}
|
||||
@@ -88,6 +157,9 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
|
||||
// Record unique article view asynchronously (doesn't block page response).
|
||||
go recordArticleView(db, article.ID, c)
|
||||
|
||||
// One-time flash notice (set by PostComment on success/pending). Reading
|
||||
// consumes the flash, so refreshing the page no longer re-shows it.
|
||||
notice := readCommentFlash(c)
|
||||
@@ -160,3 +232,68 @@ func readCommentFlash(c *gin.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// recordArticleView records a unique article view in the database.
|
||||
// This function is designed to be called asynchronously (via goroutine) to avoid
|
||||
// blocking the page response. It checks for existing records to ensure each
|
||||
// user/IP combination only records one view per article.
|
||||
func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Extract user ID from session if logged in
|
||||
var userID *uint
|
||||
if uid := session.Get("user_id"); uid != nil {
|
||||
switch v := uid.(type) {
|
||||
case uint:
|
||||
userID = &v
|
||||
case int:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case int64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case float64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP and User-Agent
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.Request.UserAgent()
|
||||
isBot := models.IsBot(userAgent)
|
||||
|
||||
// Check if this view already exists (deduplication)
|
||||
var count int64
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Where("article_id = ? AND ip = ?", articleID, ip)
|
||||
|
||||
if userID != nil {
|
||||
query = query.Where("user_id = ?", *userID)
|
||||
} else {
|
||||
query = query.Where("user_id IS NULL")
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
// Silently fail - don't break the user experience
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Already recorded
|
||||
return
|
||||
}
|
||||
|
||||
// Create new view record using INSERT IGNORE pattern
|
||||
view := models.ArticleView{
|
||||
ArticleID: articleID,
|
||||
UserID: userID,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
IsBot: isBot,
|
||||
}
|
||||
|
||||
// Create the view record (BeforeCreate hook in model handles deduplication)
|
||||
db.Create(&view)
|
||||
// Ignore errors - this is a best-effort tracking system
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user