Files
sundynix-site/server/internal/handler/visit.go
T
Blizzard 5adb3d621b feat: 用户端访问足迹统计(IP 去重 UV/PV)
- 后端 Visit 模型(date+ip 唯一索引):POST /api/track 埋点(ClientIP upsert,pv+1),GET /api/admin/visits 每日 UV/PV 统计
- web 每会话上报一次访问(sessionStorage 防重),IP 由后端去重
- admin 访问统计页:侧边栏入口 + 今日/近N天 UV·PV 卡片 + 每日柱状趋势(自绘无依赖) + 7/30/90 天切换

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:35:05 +08:00

90 lines
2.0 KiB
Go

package handler
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
)
// VisitHandler 访问足迹:用户端埋点 + 管理端统计。
type VisitHandler struct {
db *gorm.DB
}
func NewVisitHandler(db *gorm.DB) *VisitHandler {
return &VisitHandler{db: db}
}
// Track POST /api/track — 用户端埋点上报(免登录)。
// 按 (今天, ClientIP) upsert:已存在则 pv+1,否则插入 pv=1。
func (h *VisitHandler) Track(c *gin.Context) {
ip := c.ClientIP()
if ip == "" {
ip = "unknown"
}
visit := model.Visit{
Date: time.Now().Format("2006-01-02"),
IP: ip,
PV: 1,
}
h.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "ip"}},
DoUpdates: clause.Assignments(map[string]any{
"pv": gorm.Expr("pv + 1"),
"updated_at": time.Now(),
}),
}).Create(&visit)
resp.OK(c, nil)
}
type dailyRow struct {
Date string `json:"date"`
UV int64 `json:"uv"`
PV int64 `json:"pv"`
}
// Stats GET /api/admin/visits?days=30 — 近 N 天每日 UV/PV。
func (h *VisitHandler) Stats(c *gin.Context) {
days := 30
if v := c.Query("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 365 {
days = n
}
}
since := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
today := time.Now().Format("2006-01-02")
var rows []dailyRow
h.db.Model(&model.Visit{}).
Select("date, count(*) as uv, coalesce(sum(pv), 0) as pv").
Where("date >= ?", since).
Group("date").
Order("date").
Scan(&rows)
var totalUV, totalPV, todayUV, todayPV int64
for _, r := range rows {
totalUV += r.UV
totalPV += r.PV
if r.Date == today {
todayUV = r.UV
todayPV = r.PV
}
}
resp.OK(c, gin.H{
"daily": rows,
"total_uv": totalUV,
"total_pv": totalPV,
"today_uv": todayUV,
"today_pv": todayPV,
"days": days,
})
}