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>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// Visit 用户端访问记录,按 (date, ip) 去重:每行代表某 IP 某天来过(UV);
|
||||
// pv 记该 IP 当天访问次数。表名 sundynix_visit。
|
||||
type Visit struct {
|
||||
BaseModel
|
||||
Date string `gorm:"size:10;uniqueIndex:uk_date_ip" json:"date"`
|
||||
IP string `gorm:"size:64;uniqueIndex:uk_date_ip" json:"ip"`
|
||||
PV int64 `json:"pv"`
|
||||
}
|
||||
@@ -30,6 +30,8 @@ func New(db *gorm.DB) (*gin.Engine, error) {
|
||||
api.GET("/posts/:slug", posts.Get)
|
||||
|
||||
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
|
||||
|
||||
api.POST("/track", handler.NewVisitHandler(db).Track)
|
||||
}
|
||||
|
||||
// RSS 订阅源(页脚 RSS 链接指向这里)
|
||||
@@ -43,6 +45,7 @@ func New(db *gorm.DB) (*gin.Engine, error) {
|
||||
{
|
||||
posts := handler.NewAdminPostHandler(db)
|
||||
adminAPI.GET("/stats", posts.Stats)
|
||||
adminAPI.GET("/visits", handler.NewVisitHandler(db).Stats)
|
||||
adminAPI.GET("/posts", posts.List)
|
||||
adminAPI.POST("/posts", posts.Create)
|
||||
adminAPI.GET("/posts/:id", posts.Get)
|
||||
|
||||
@@ -34,7 +34,7 @@ func Open(dsn string) (*gorm.DB, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Post{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.Post{}, &model.Visit{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := seed(db); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user