Files
sundynix-site/server/internal/router/router.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

96 lines
2.6 KiB
Go

package router
import (
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/handler"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/middleware"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/webfs"
)
func New(db *gorm.DB) (*gin.Engine, error) {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
// ── 公开 API ──
api := r.Group("/api")
{
api.GET("/healthz", func(c *gin.Context) {
resp.OK(c, gin.H{"status": "ok"})
})
posts := handler.NewPostHandler(db)
api.GET("/posts", posts.List)
api.GET("/posts/:slug", posts.Get)
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
api.POST("/track", handler.NewVisitHandler(db).Track)
}
// RSS 订阅源(页脚 RSS 链接指向这里)
r.GET("/rss.xml", handler.NewRSSHandler(db).Feed)
// ── 管理端 API ──
adminAuth := handler.NewAdminAuthHandler()
api.POST("/admin/login", adminAuth.Login)
adminAPI := api.Group("/admin", middleware.Auth())
{
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)
adminAPI.PUT("/posts/:id", posts.Update)
adminAPI.DELETE("/posts/:id", posts.Delete)
}
// ── 静态站点:/admin → 管理端,其余 → 用户端 ──
webDist, err := webfs.WebDist()
if err != nil {
return nil, err
}
adminDist, err := webfs.AdminDist()
if err != nil {
return nil, err
}
webServer := http.FileServer(http.FS(webDist))
adminServer := http.StripPrefix("/admin", http.FileServer(http.FS(adminDist)))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") {
resp.NotFound(c, "接口不存在")
return
}
if path == "/admin" || strings.HasPrefix(path, "/admin/") {
serveSPA(c, adminDist, adminServer, strings.TrimPrefix(path, "/admin"), "/admin/")
return
}
serveSPA(c, webDist, webServer, path, "/")
})
return r, nil
}
// serveSPA 真实文件直接吐,否则回退 index.html 交给前端路由。
func serveSPA(c *gin.Context, dist fs.FS, server http.Handler, rel, fallback string) {
p := strings.TrimPrefix(rel, "/")
if p != "" {
if _, err := fs.Stat(dist, p); err == nil {
server.ServeHTTP(c.Writer, c.Request)
return
}
}
c.Request.URL.Path = fallback
server.ServeHTTP(c.Writer, c.Request)
}