From 3bcef22487ed60a4a16517fc9d4cb63ea70d8492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 16 Jan 2026 09:32:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E4=BD=9C=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 2 + client/pnpm-lock.yaml | 37 ++ client/src/components/admin/AdminLayout.vue | 163 +++++++- client/src/pages/admin/Dashboard.vue | 362 ------------------ client/src/pages/admin/dashboard/Overview.vue | 273 +++++++++++++ client/src/router.ts | 2 +- server/go.mod | 1 + server/go.sum | 2 + server/handlers/dashboard.go | 59 +-- server/main.go | 50 +-- server/middleware/access_log.go | 44 +++ server/models/access_log.go | 16 + server/repositories/log_repository.go | 91 +++++ server/repositories/post_repository.go | 72 ++++ server/utils/ip.go | 77 ++++ 15 files changed, 819 insertions(+), 432 deletions(-) delete mode 100644 client/src/pages/admin/Dashboard.vue create mode 100644 client/src/pages/admin/dashboard/Overview.vue create mode 100644 server/middleware/access_log.go create mode 100644 server/models/access_log.go create mode 100644 server/repositories/log_repository.go create mode 100644 server/utils/ip.go diff --git a/client/package.json b/client/package.json index 5954415..6e4fa6b 100644 --- a/client/package.json +++ b/client/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@types/markdown-it": "^14.1.2", + "echarts": "^6.0.0", "highlight": "^0.2.4", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "md-editor-v3": "^6.3.1", "vue": "^3.5.13", + "vue-echarts": "^8.0.1", "vue-router": "^4.4.5" }, "devDependencies": { diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 78d240c..df52842 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + echarts: + specifier: ^6.0.0 + version: 6.0.0 highlight: specifier: ^0.2.4 version: 0.2.4 @@ -26,6 +29,9 @@ importers: vue: specifier: ^3.5.13 version: 3.5.26(typescript@5.6.3) + vue-echarts: + specifier: ^8.0.1 + version: 8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.6.3)) vue-router: specifier: ^4.4.5 version: 4.6.4(vue@3.5.26(typescript@5.6.3)) @@ -692,6 +698,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1023,6 +1032,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + typescript@5.6.3: resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} engines: {node: '>=14.17'} @@ -1083,6 +1095,12 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-echarts@8.0.1: + resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -1110,6 +1128,9 @@ packages: engines: {node: '>= 0.10.0'} hasBin: true + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -1843,6 +1864,11 @@ snapshots: dlv@1.1.3: {} + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + electron-to-chromium@1.5.267: {} entities@4.5.0: {} @@ -2207,6 +2233,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.3.0: {} + typescript@5.6.3: {} uc.micro@2.1.0: {} @@ -2233,6 +2261,11 @@ snapshots: vscode-uri@3.1.0: {} + vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.26(typescript@5.6.3)): + dependencies: + echarts: 6.0.0 + vue: 3.5.26(typescript@5.6.3) + vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 @@ -2260,3 +2293,7 @@ snapshots: dependencies: commander: 2.20.3 cssfilter: 0.0.10 + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/client/src/components/admin/AdminLayout.vue b/client/src/components/admin/AdminLayout.vue index b08c5ef..3b0db5a 100644 --- a/client/src/components/admin/AdminLayout.vue +++ b/client/src/components/admin/AdminLayout.vue @@ -45,12 +45,7 @@ - + @@ -63,9 +58,33 @@
- @@ -79,7 +98,7 @@ @@ -184,6 +228,105 @@ onMounted(() => { color: #ececec; } +/* User Dropdown Styles */ +.user-dropdown { + position: relative; +} + +.user-info-btn { + display: flex; + align-items: center; + gap: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.375rem; + transition: background-color 0.2s; +} + +.user-info-btn:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.arrow { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.5); + transition: transform 0.2s; +} + +.arrow.rotated { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + width: 200px; + background: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + z-index: 100; + overflow: hidden; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.dropdown-header { + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.02); +} + +.user-email { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.5); + display: block; +} + +.dropdown-list { + list-style: none; + padding: 0.5rem 0; + margin: 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; + cursor: pointer; + text-align: left; + transition: background-color 0.2s; +} + +.dropdown-item:hover { + background-color: rgba(212, 179, 131, 0.1); + color: #d4b383; +} + +.dropdown-item .icon { + font-size: 1.1rem; +} + +.divider { + height: 1px; + background-color: rgba(255, 255, 255, 0.05); + margin: 0.5rem 0; +} + /* Sidebar Styles */ .admin-sidebar { width: 250px; diff --git a/client/src/pages/admin/Dashboard.vue b/client/src/pages/admin/Dashboard.vue deleted file mode 100644 index 95755fa..0000000 --- a/client/src/pages/admin/Dashboard.vue +++ /dev/null @@ -1,362 +0,0 @@ - - - - - diff --git a/client/src/pages/admin/dashboard/Overview.vue b/client/src/pages/admin/dashboard/Overview.vue new file mode 100644 index 0000000..4b4b409 --- /dev/null +++ b/client/src/pages/admin/dashboard/Overview.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/client/src/router.ts b/client/src/router.ts index 66053d6..b354ba3 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -21,7 +21,7 @@ const routes = [ children: [ // 仪表盘 { path: '', redirect: '/admin/dashboard' }, - { path: 'dashboard', name: 'admin-dashboard', component: () => import('./pages/admin/Dashboard.vue') }, + { path: 'dashboard', name: 'admin-dashboard', component: () => import('./pages/admin/dashboard/Overview.vue') }, // 用户管理 { path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') }, diff --git a/server/go.mod b/server/go.mod index 236e555..bf9ca7e 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index e516273..0c12341 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/handlers/dashboard.go b/server/handlers/dashboard.go index 1d8728c..fd5f24e 100644 --- a/server/handlers/dashboard.go +++ b/server/handlers/dashboard.go @@ -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, + }) } diff --git a/server/main.go b/server/main.go index a15cf15..59a130b 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/middleware/access_log.go b/server/middleware/access_log.go new file mode 100644 index 0000000..528ae23 --- /dev/null +++ b/server/middleware/access_log.go @@ -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) + }() + } +} diff --git a/server/models/access_log.go b/server/models/access_log.go new file mode 100644 index 0000000..3944e2f --- /dev/null +++ b/server/models/access_log.go @@ -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"` +} diff --git a/server/repositories/log_repository.go b/server/repositories/log_repository.go new file mode 100644 index 0000000..00f2590 --- /dev/null +++ b/server/repositories/log_repository.go @@ -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 +} diff --git a/server/repositories/post_repository.go b/server/repositories/post_repository.go index fc5a9f8..365610d 100644 --- a/server/repositories/post_repository.go +++ b/server/repositories/post_repository.go @@ -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 +} diff --git a/server/utils/ip.go b/server/utils/ip.go new file mode 100644 index 0000000..470b633 --- /dev/null +++ b/server/utils/ip.go @@ -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) +}