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:
2026-06-22 19:57:12 +08:00
co-authored by Claude Fable 5
parent 5ec783e471
commit da7a39c1c8
16 changed files with 1701 additions and 5 deletions
+138 -1
View File
@@ -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
}