合作页面的内容

This commit is contained in:
李琦
2026-01-16 09:32:46 +08:00
parent a23b718865
commit 3bcef22487
15 changed files with 819 additions and 432 deletions

View File

@@ -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

View File

@@ -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=

View File

@@ -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,
})
}

View File

@@ -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)

View 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)
}()
}
}

View 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"`
}

View 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
}

View File

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