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
+123
View File
@@ -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)
}
}
+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
}