合作页面的内容
This commit is contained in:
@@ -25,6 +25,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
|
||||
@@ -42,6 +42,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54 h1:R0OB+D7w26BOVPdoOva58AgTKmJR8tK2KI15XeeLdG8=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
|
||||
@@ -4,43 +4,56 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/repositories"
|
||||
)
|
||||
|
||||
// 仪表盘数据处理函数
|
||||
func AdminGetDashboardStats(c *gin.Context) {
|
||||
// 获取统计数据
|
||||
userCount, err := repositories.GetUserCount()
|
||||
// GetDashboardStats 获取仪表盘统计数据
|
||||
func GetDashboardStats(c *gin.Context) {
|
||||
// 1. 获取博文增长趋势 (最近7天新增)
|
||||
postsTrend, err := repositories.GetNewPostsTrend()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user count"})
|
||||
return
|
||||
postsTrend = []struct {
|
||||
Date string
|
||||
Count int
|
||||
}{} // Fallback empty
|
||||
}
|
||||
|
||||
postCount, err := repositories.GetPostCount()
|
||||
// 2. 获取今日访客UV (根据IP)
|
||||
// 这里可以复用GetDailyUV获取最近7天,取最后一天即为今日(或者根据前端需要展示7天趋势)
|
||||
uvTrend, err := repositories.GetDailyUV()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get post count"})
|
||||
return
|
||||
uvTrend = []struct {
|
||||
Date string
|
||||
Count int
|
||||
}{}
|
||||
}
|
||||
|
||||
workCount, err := repositories.GetWorkCount()
|
||||
// 3. 用户画像-地域分布
|
||||
userRegions, err := repositories.GetUserRegions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get work count"})
|
||||
return
|
||||
userRegions = []struct {
|
||||
Region string
|
||||
Count int
|
||||
}{}
|
||||
}
|
||||
|
||||
snippetCount, err := repositories.GetSnippetCount()
|
||||
// 4. 热门文章 Top 5
|
||||
// 需要在Post repository增加方法
|
||||
topPosts, err := repositories.GetTopPosts(5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get snippet count"})
|
||||
return
|
||||
topPosts = nil // Fallback
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
stats := gin.H{
|
||||
"users": userCount,
|
||||
"posts": postCount,
|
||||
"works": workCount,
|
||||
"snippets": snippetCount,
|
||||
}
|
||||
// 5. 合作咨询总数
|
||||
var inquiryCount int
|
||||
config.DB.QueryRow("SELECT COUNT(*) FROM inquiries").Scan(&inquiryCount)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"postsTrend": postsTrend,
|
||||
"uvTrend": uvTrend,
|
||||
"userRegions": userRegions,
|
||||
"topPosts": topPosts,
|
||||
"inquiryCount": inquiryCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/handlers"
|
||||
"github.com/niangaodev/art-code/middleware"
|
||||
"github.com/niangaodev/art-code/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -17,40 +15,17 @@ func main() {
|
||||
config.InitDB()
|
||||
defer config.CloseDB()
|
||||
|
||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||
// 请确保在server根目录或合适位置放入 ip2region.xdb
|
||||
utils.InitIP2Region("ip2region.xdb")
|
||||
|
||||
// 创建Gin引擎
|
||||
router := gin.Default()
|
||||
router := gin.New()
|
||||
|
||||
// 配置CORS
|
||||
router.Use(func(c *gin.Context) {
|
||||
// 获取允许的域名,优先从环境变量获取,否则使用默认值
|
||||
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
||||
if allowedOrigins == "" {
|
||||
allowedOrigins = "http://localhost:5173,http://localhost:3000" // 开发环境默认值
|
||||
}
|
||||
|
||||
// 检查请求来源是否在允许列表中
|
||||
requestOrigin := c.Request.Header.Get("Origin")
|
||||
if requestOrigin != "" {
|
||||
// 简单的CORS origin检查
|
||||
for _, origin := range strings.Split(allowedOrigins, ",") {
|
||||
if strings.TrimSpace(origin) == requestOrigin {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", requestOrigin)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
// 配置中间件
|
||||
router.Use(middleware.CorsMiddleware())
|
||||
router.Use(middleware.AccessLogMiddleware()) // 添加访问日志中间件
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
// API路由组
|
||||
api := router.Group("/api")
|
||||
@@ -132,9 +107,12 @@ func main() {
|
||||
authAdmin.PUT("/settings", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateSetting)
|
||||
authAdmin.DELETE("/settings/:key", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteSetting)
|
||||
|
||||
// 仪表盘统计
|
||||
authAdmin.GET("/dashboard/stats", middleware.PermissionMiddleware("dashboard", "read"), handlers.GetDashboardStats)
|
||||
|
||||
// 文章管理
|
||||
authAdmin.GET("/posts", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPosts)
|
||||
authAdmin.POST("/posts", middleware.PermissionMiddleware("posts", "create"), handlers.AdminCreatePost)
|
||||
authAdmin.POST("/posts", middleware.PermissionMiddleware("posts", "create"), handlers.CreatePost)
|
||||
authAdmin.PUT("/posts/:id", middleware.PermissionMiddleware("posts", "update"), handlers.AdminUpdatePost)
|
||||
authAdmin.DELETE("/posts/:id", middleware.PermissionMiddleware("posts", "delete"), handlers.AdminDeletePost)
|
||||
|
||||
|
||||
44
server/middleware/access_log.go
Normal file
44
server/middleware/access_log.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
"github.com/niangaodev/art-code/repositories"
|
||||
"github.com/niangaodev/art-code/utils"
|
||||
)
|
||||
|
||||
// AccessLogMiddleware 访问日志中间件 (用于流量统计)
|
||||
func AccessLogMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
// 过滤静态资源和API调用
|
||||
// 这里只记录主要页面访问,或者我们可以记录所有非静态资源
|
||||
// 简化起见,记录所有请求,分析时再过滤
|
||||
|
||||
// 获取IP
|
||||
ip := c.ClientIP()
|
||||
|
||||
// 获取归属地 (需要先初始化ip2region)
|
||||
region := utils.GetRegion(ip)
|
||||
|
||||
log := &models.AccessLog{
|
||||
IP: ip,
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
Path: c.Request.URL.Path,
|
||||
Method: c.Request.Method,
|
||||
StatusCode: c.Writer.Status(),
|
||||
ResponseTime: time.Since(start).Milliseconds(),
|
||||
Region: region,
|
||||
}
|
||||
|
||||
// 异步入库
|
||||
go func() {
|
||||
repositories.CreateAccessLog(log)
|
||||
}()
|
||||
}
|
||||
}
|
||||
16
server/models/access_log.go
Normal file
16
server/models/access_log.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// AccessLog 访问日志 (用于统计流量)
|
||||
type AccessLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
StatusCode int `json:"status_code"`
|
||||
ResponseTime int64 `json:"response_time"` // 毫秒
|
||||
Region string `json:"region"` // IP归属地
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
91
server/repositories/log_repository.go
Normal file
91
server/repositories/log_repository.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
)
|
||||
|
||||
// CreateAccessLog 创建访问日志
|
||||
func CreateAccessLog(log *models.AccessLog) error {
|
||||
query := `INSERT INTO access_logs (ip, user_agent, path, method, status_code, response_time, region) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
_, err := config.DB.Exec(query, log.IP, log.UserAgent, log.Path, log.Method, log.StatusCode, log.ResponseTime, log.Region)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDailyUV 获取最近7天UV
|
||||
func GetDailyUV() ([]struct {
|
||||
Date string
|
||||
Count int
|
||||
}, error) {
|
||||
query := `
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as date, COUNT(DISTINCT ip) as count
|
||||
FROM access_logs
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`
|
||||
rows, err := config.DB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []struct {
|
||||
Date string
|
||||
Count int
|
||||
}
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
Date string
|
||||
Count int
|
||||
}
|
||||
if err := rows.Scan(&r.Date, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserRegions 获取用户地域分布
|
||||
func GetUserRegions() ([]struct {
|
||||
Region string
|
||||
Count int
|
||||
}, error) {
|
||||
// 简单的截取省份逻辑,实际可能需要更复杂的解析,这里假设region格式为 国家|区域|省份|城市|ISP
|
||||
// 我们可以取省份 (第3部分)
|
||||
query := `
|
||||
SELECT
|
||||
SUBSTRING_INDEX(SUBSTRING_INDEX(region, '|', 3), '|', -1) as province,
|
||||
COUNT(DISTINCT ip) as count
|
||||
FROM access_logs
|
||||
WHERE region != 'Internal' AND region != 'Unknown' AND region IS NOT NULL
|
||||
GROUP BY province
|
||||
ORDER BY count DESC
|
||||
LIMIT 20
|
||||
`
|
||||
rows, err := config.DB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []struct {
|
||||
Region string
|
||||
Count int
|
||||
}
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
Region string
|
||||
Count int
|
||||
}
|
||||
if err := rows.Scan(&r.Region, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 过滤掉无效数据
|
||||
if r.Region != "" && r.Region != "0" {
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -375,3 +375,75 @@ func BuildPostHistoryResponses(history []models.PostHistory) []models.PostHistor
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// GetNewPostsTrend 获取最近7天新增文章趋势
|
||||
func GetNewPostsTrend() ([]struct {
|
||||
Date string
|
||||
Count int
|
||||
}, error) {
|
||||
query := `
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as date, COUNT(*) as count
|
||||
FROM posts
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`
|
||||
rows, err := config.DB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []struct {
|
||||
Date string
|
||||
Count int
|
||||
}
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
Date string
|
||||
Count int
|
||||
}
|
||||
if err := rows.Scan(&r.Date, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetTopPosts 获取热门文章 (按阅读量)
|
||||
func GetTopPosts(limit int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT id, title, category, date, excerpt, content, read_count, is_published, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE is_published = 1
|
||||
ORDER BY read_count DESC
|
||||
LIMIT ?
|
||||
`
|
||||
rows, err := config.DB.Query(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var post models.Post
|
||||
if err := rows.Scan(
|
||||
&post.ID,
|
||||
&post.Title,
|
||||
&post.Category,
|
||||
&post.Date,
|
||||
&post.Excerpt,
|
||||
&post.Content,
|
||||
&post.ReadCount,
|
||||
&post.IsPublished,
|
||||
&post.CreatedAt,
|
||||
&post.UpdatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
77
server/utils/ip.go
Normal file
77
server/utils/ip.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
)
|
||||
|
||||
var (
|
||||
searcher *xdb.Searcher
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// InitIP2Region 初始化 ip2region
|
||||
// 需要 ip2region.xdb 文件,如果不存在则仅支持基本IP解析
|
||||
func InitIP2Region(dbPath string) {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
// 1. 尝试加载整个xdb到内存,性能最好
|
||||
cBuff, err := xdb.LoadContentFromFile(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load ip2region.xdb: %v. Region lookup will be disabled.", err)
|
||||
return
|
||||
}
|
||||
|
||||
searcher, err = xdb.NewWithBuffer(cBuff)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create searcher: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("IP2Region loaded successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// GetRegion 获取IP归属地
|
||||
// 返回格式: 国家|区域|省份|城市|ISP
|
||||
func GetRegion(ip string) string {
|
||||
if searcher == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// 过滤内网IP
|
||||
if isPrivateIP(ip) {
|
||||
return "Internal"
|
||||
}
|
||||
|
||||
region, err := searcher.SearchByStr(ip)
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
return region
|
||||
}
|
||||
|
||||
// 简单判断内网IP
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false // 不是有效IP
|
||||
}
|
||||
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
ip4 := ip.To4()
|
||||
if ip4 == nil {
|
||||
return false // 暂不处理IPv6内网判断
|
||||
}
|
||||
|
||||
return ip4[0] == 10 ||
|
||||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
|
||||
(ip4[0] == 192 && ip4[1] == 168)
|
||||
}
|
||||
Reference in New Issue
Block a user