diff --git a/client/src/components/Footer.vue b/client/src/components/Footer.vue
index f067aef..e01fa86 100644
--- a/client/src/components/Footer.vue
+++ b/client/src/components/Footer.vue
@@ -3,12 +3,12 @@
© 2024 年糕崽崽. 保留所有权利.
+ © 2026 年糕崽崽. 保留所有权利.
\ No newline at end of file
diff --git a/client/src/components/admin/AccessLogModal.vue b/client/src/components/admin/AccessLogModal.vue
new file mode 100644
index 0000000..9481e9e
--- /dev/null
+++ b/client/src/components/admin/AccessLogModal.vue
@@ -0,0 +1,182 @@
+([
isOpen: false,
children: [
{ title: '全局配置', path: '/admin/settings', icon: '🛠️' },
- { title: '操作日志', path: '/admin/logs', icon: '📋' }
+ { title: '操作日志', path: '/admin/logs', icon: '📋' },
+ { title: '访问日志', path: '/admin/access-logs', icon: '🌐' }
]
}
])
diff --git a/client/src/pages/admin/AccessLogs.vue b/client/src/pages/admin/AccessLogs.vue
new file mode 100644
index 0000000..c401af8
--- /dev/null
+++ b/client/src/pages/admin/AccessLogs.vue
@@ -0,0 +1,356 @@
+
+
+
访问日志管理
+
+
+
+
+
+
+
+ ID
+ IP地址
+ 归属地
+ 路径
+ 方法
+ 状态
+ 响应时间(ms)
+ 访问时间
+
+
+
+
+ {{ log.id }}
+ {{ log.ip }}
+ {{ log.region || 'Unknown' }}
+ {{ log.path }}
+
+
+ {{ log.method }}
+
+
+
+
+ {{ log.statusCode }}
+
+
+ {{ log.responseTime }}
+ {{ formatDate(log.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/pages/admin/Logs.vue b/client/src/pages/admin/Logs.vue
index a214d93..621d22a 100644
--- a/client/src/pages/admin/Logs.vue
+++ b/client/src/pages/admin/Logs.vue
@@ -23,6 +23,7 @@
ID
操作人
IP地址
+ 归属地
路径
方法
状态
@@ -34,7 +35,8 @@
{{ log.id }}
{{ log.username }}
- {{ log.ip }}
+ {{ log.ip }}
+ {{ log.region || 'Unknown' }}
{{ log.path }}
diff --git a/client/src/pages/admin/Posts.vue b/client/src/pages/admin/Posts.vue
index caed6da..dbfa003 100644
--- a/client/src/pages/admin/Posts.vue
+++ b/client/src/pages/admin/Posts.vue
@@ -20,6 +20,7 @@
标签
发布日期
状态
+ 访问记录
操作
@@ -57,6 +58,15 @@
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
+
+
+ 查看
+
+
@@ -91,6 +101,13 @@
:post="editingPost"
@saved="fetchPosts"
/>
+
+
+
@@ -99,17 +116,27 @@ import { ref, onMounted } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
import { useToast } from '../../composables/useToast'
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
+import AccessLogModal from '../../components/admin/AccessLogModal.vue'
const toast = useToast()
const posts = ref([])
const isRelationModalOpen = ref(false)
const editingPost = ref(null)
+const isAccessLogModalOpen = ref(false)
+const selectedPostId = ref(null)
+const selectedPostTitle = ref('')
const openRelationModal = (post: Post) => {
editingPost.value = post
isRelationModalOpen.value = true
}
+const openAccessLogModal = (post: Post) => {
+ selectedPostId.value = post.id
+ selectedPostTitle.value = post.title
+ isAccessLogModalOpen.value = true
+}
+
const fetchPosts = async () => {
try {
const response = await getAdminPosts()
diff --git a/client/src/pages/admin/dashboard/Analytics.vue b/client/src/pages/admin/dashboard/Analytics.vue
index a23b1f0..5dc7ead 100644
--- a/client/src/pages/admin/dashboard/Analytics.vue
+++ b/client/src/pages/admin/dashboard/Analytics.vue
@@ -100,13 +100,16 @@
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
-import { BarChart, LineChart, PieChart } from 'echarts/charts'
+import { BarChart, LineChart, PieChart, MapChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
- TitleComponent
+ TitleComponent,
+ GeoComponent,
+ VisualMapComponent
} from 'echarts/components'
+import * as echarts from 'echarts/core'
import VChart from 'vue-echarts'
import { getDashboardStats } from '../../../services/api'
import { useToast } from '../../../composables/useToast'
@@ -116,10 +119,13 @@ use([
BarChart,
LineChart,
PieChart,
+ MapChart,
GridComponent,
TooltipComponent,
LegendComponent,
- TitleComponent
+ TitleComponent,
+ GeoComponent,
+ VisualMapComponent
])
const toast = useToast()
@@ -272,39 +278,156 @@ const uvTrendOption = computed(() => ({
}]
}))
-const regionOption = computed(() => ({
- tooltip: {
- trigger: 'item',
- backgroundColor: 'rgba(0,0,0,0.8)',
- borderColor: '#333',
- textStyle: { color: '#fff' },
- formatter: '{b}: {c} ({d}%)'
- },
- legend: {
- orient: 'vertical',
- left: 'left',
- textStyle: { color: '#ccc' }
- },
- series: [
- {
- name: '访问来源',
- type: 'pie',
- radius: ['40%', '70%'],
- avoidLabelOverlap: false,
+// 省份名称映射(确保与地图数据一致)
+const provinceNameMap: Record = {
+ '北京': '北京',
+ '天津': '天津',
+ '河北': '河北',
+ '山西': '山西',
+ '内蒙古': '内蒙古',
+ '辽宁': '辽宁',
+ '吉林': '吉林',
+ '黑龙江': '黑龙江',
+ '上海': '上海',
+ '江苏': '江苏',
+ '浙江': '浙江',
+ '安徽': '安徽',
+ '福建': '福建',
+ '江西': '江西',
+ '山东': '山东',
+ '河南': '河南',
+ '湖北': '湖北',
+ '湖南': '湖南',
+ '广东': '广东',
+ '广西': '广西',
+ '海南': '海南',
+ '重庆': '重庆',
+ '四川': '四川',
+ '贵州': '贵州',
+ '云南': '云南',
+ '西藏': '西藏',
+ '陕西': '陕西',
+ '甘肃': '甘肃',
+ '青海': '青海',
+ '宁夏': '宁夏',
+ '新疆': '新疆',
+ '台湾': '台湾',
+ '香港': '香港',
+ '澳门': '澳门'
+}
+
+// 所有省份列表(用于设置默认值)
+const allProvinces = [
+ '北京', '天津', '河北', '山西', '内蒙古', '辽宁', '吉林', '黑龙江',
+ '上海', '江苏', '浙江', '安徽', '福建', '江西', '山东', '河南',
+ '湖北', '湖南', '广东', '广西', '海南', '重庆', '四川', '贵州',
+ '云南', '西藏', '陕西', '甘肃', '青海', '宁夏', '新疆', '台湾', '香港', '澳门'
+]
+
+// 将数据转换为地图需要的格式,没有值的省份默认为0
+const getMapData = () => {
+ // 创建省份数据映射
+ const dataMap = new Map()
+
+ // 如果有数据,先填充实际数据
+ if (stats.value.userRegions && stats.value.userRegions.length > 0) {
+ stats.value.userRegions.forEach((item: any) => {
+ let provinceName = item.Region
+ // 标准化省份名称(移除"省"、"市"、"自治区"等后缀)
+ provinceName = provinceName.replace(/省|市|自治区|特别行政区|壮族自治区|维吾尔自治区|回族自治区/g, '')
+
+ // 特殊处理
+ if (provinceName === '内蒙古') provinceName = '内蒙古'
+ if (provinceName === '广西') provinceName = '广西'
+ if (provinceName === '西藏') provinceName = '西藏'
+ if (provinceName === '宁夏') provinceName = '宁夏'
+ if (provinceName === '新疆') provinceName = '新疆'
+
+ dataMap.set(provinceName, item.Count || 0)
+ })
+ }
+
+ // 为所有省份生成数据,没有值的默认为0
+ return allProvinces.map(province => {
+ return [province, dataMap.get(province) || 0]
+ })
+}
+
+const regionOption = computed(() => {
+ const mapData = getMapData()
+ const maxValue = mapData.length > 0 ? Math.max(...mapData.map((d: any) => d[1] || 0)) : 1
+
+ return {
+ tooltip: {
+ trigger: 'item',
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ borderColor: '#333',
+ textStyle: { color: '#fff' },
+ formatter: (params: any) => {
+ const value = params.value ? (Array.isArray(params.value) ? params.value[1] : params.value) : 0
+ return `${params.name} 访问量: ${value}`
+ }
+ },
+ visualMap: {
+ min: 0,
+ max: maxValue || 100,
+ left: 'left',
+ top: 'bottom',
+ text: ['高', '低'],
+ inRange: {
+ color: ['#1a1a1a', '#d4b383']
+ },
+ textStyle: {
+ color: '#fff'
+ },
+ calculable: true
+ },
+ geo: {
+ map: 'china',
+ roam: false,
itemStyle: {
- borderRadius: 10,
- borderColor: '#050505',
- borderWidth: 2
+ areaColor: 'rgba(255, 255, 255, 0.05)',
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ borderWidth: 1
},
- label: { show: false, position: 'center' },
emphasis: {
- label: { show: true, fontSize: 20, fontWeight: 'bold', color: '#fff' }
+ itemStyle: {
+ areaColor: 'rgba(212, 179, 131, 0.3)'
+ }
},
- labelLine: { show: false },
- data: stats.value.userRegions?.map((i: any) => ({ value: i.Count, name: i.Region })) || []
- }
- ]
-}))
+ label: {
+ show: true,
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: 10
+ }
+ },
+ series: [
+ {
+ name: '访问量',
+ type: 'map',
+ map: 'china',
+ geoIndex: 0,
+ data: mapData,
+ itemStyle: {
+ areaColor: 'rgba(255, 255, 255, 0.05)',
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ borderWidth: 1
+ },
+ emphasis: {
+ itemStyle: {
+ areaColor: 'rgba(212, 179, 131, 0.5)',
+ borderColor: '#d4b383',
+ borderWidth: 2
+ },
+ label: {
+ color: '#fff',
+ fontSize: 12
+ }
+ }
+ }
+ ]
+ }
+})
const topPostsOption = computed(() => ({
tooltip: {
@@ -352,7 +475,26 @@ const loadData = async () => {
}
}
-onMounted(() => {
+// 加载中国地图数据
+const loadChinaMap = async () => {
+ try {
+ // 使用 echarts 官方的中国地图数据(从 CDN 加载)
+ const response = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
+ if (response.ok) {
+ const geoJson = await response.json()
+ // 注册地图数据
+ echarts.registerMap('china', geoJson)
+ } else {
+ console.warn('Failed to load China map data, using fallback')
+ }
+ } catch (error) {
+ console.error('Error loading China map:', error)
+ }
+}
+
+onMounted(async () => {
+ // 加载地图数据
+ await loadChinaMap()
// Default to 7 days
selectRange('7d')
})
diff --git a/client/src/router.ts b/client/src/router.ts
index 6daaab3..6227e47 100644
--- a/client/src/router.ts
+++ b/client/src/router.ts
@@ -91,6 +91,9 @@ const routes = [
// 操作日志
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') },
+ // 访问日志
+ { path: 'access-logs', name: 'admin-access-logs', component: () => import('./pages/admin/AccessLogs.vue') },
+
// 附件管理
{ path: 'attachments', name: 'admin-attachments', component: () => import('./pages/admin/Attachments.vue') },
diff --git a/client/src/services/api.ts b/client/src/services/api.ts
index 535dddc..de56af2 100644
--- a/client/src/services/api.ts
+++ b/client/src/services/api.ts
@@ -1101,6 +1101,55 @@ export const getOperationLogs = async (page: number = 1, pageSize: number = 10):
}
}
+// 访问日志类型
+export interface AccessLog {
+ id: number
+ ip: string
+ userAgent: string
+ path: string
+ method: string
+ statusCode: number
+ responseTime: number
+ region: string
+ createdAt: string
+}
+
+// 访问日志API
+export const getAccessLogs = async (page: number = 1, pageSize: number = 10): Promise> => {
+ try {
+ const response = await fetch(`${API_BASE}/admin/access-logs?page=${page}&pageSize=${pageSize}`, {
+ headers: getAuthHeaders()
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.message || '获取访问日志失败')
+ }
+ const data = await response.json()
+ return data.result
+ } catch (error) {
+ console.error('Get access logs error:', error)
+ throw error
+ }
+}
+
+// 获取指定文章的访问记录
+export const getPostAccessLogs = async (postId: number, page: number = 1, pageSize: number = 20): Promise> => {
+ try {
+ const response = await fetch(`${API_BASE}/admin/posts/${postId}/access-logs?page=${page}&pageSize=${pageSize}`, {
+ headers: getAuthHeaders()
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.message || '获取文章访问记录失败')
+ }
+ const data = await response.json()
+ return data.result
+ } catch (error) {
+ console.error('Get post access logs error:', error)
+ throw error
+ }
+}
+
// 仪表盘数据类型
export interface DashboardStats {
users: number
diff --git a/server/go.mod b/server/go.mod
index 4167c79..06117b2 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -8,6 +8,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
+ github.com/joho/godotenv v1.5.1
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54
github.com/qiniu/go-sdk/v7 v7.25.6
github.com/tencentyun/cos-go-sdk-v5 v0.7.72
@@ -36,7 +37,6 @@ require (
github.com/google/go-querystring v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/joho/godotenv v1.5.1 // indirect
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
diff --git a/server/handlers/log.go b/server/handlers/log.go
index b111d7d..2ea5741 100644
--- a/server/handlers/log.go
+++ b/server/handlers/log.go
@@ -125,3 +125,122 @@ func AdminGetOperationLogs(c *gin.Context) {
utils.Success(c, res)
}
+
+// AdminGetAccessLogs 获取访问日志列表
+func AdminGetAccessLogs(c *gin.Context) {
+ // 获取分页参数
+ page := 1
+ pageSize := 10
+
+ // 从查询参数中获取分页信息
+ if c.Query("page") != "" {
+ if p, err := strconv.Atoi(c.Query("page")); err == nil {
+ page = p
+ }
+ }
+
+ if c.Query("pageSize") != "" {
+ if ps, err := strconv.Atoi(c.Query("pageSize")); err == nil {
+ pageSize = ps
+ }
+ }
+
+ // 获取访问日志
+ logs, total, err := repositories.GetAccessLogs(page, pageSize, nil)
+ if err != nil {
+ utils.ServerError(c, err)
+ return
+ }
+
+ // 构建响应
+ var logList []gin.H
+ for _, log := range logs {
+ logList = append(logList, gin.H{
+ "id": log.ID,
+ "ip": log.IP,
+ "userAgent": log.UserAgent,
+ "path": log.Path,
+ "method": log.Method,
+ "statusCode": log.StatusCode,
+ "responseTime": log.ResponseTime,
+ "region": log.Region,
+ "createdAt": time.Unix(log.CreatedAt, 0).Format("2006-01-02 15:04:05"),
+ })
+ }
+
+ if logList == nil {
+ logList = []gin.H{}
+ }
+
+ res := gin.H{
+ "list": logList,
+ "total": total,
+ "page": page,
+ "size": pageSize,
+ }
+
+ utils.Success(c, res)
+}
+
+// AdminGetPostAccessLogs 获取指定文章的访问记录
+func AdminGetPostAccessLogs(c *gin.Context) {
+ // 获取文章ID
+ postIDStr := c.Param("id")
+ postID, err := strconv.Atoi(postIDStr)
+ if err != nil {
+ utils.Error(c, 400, "Invalid post ID")
+ return
+ }
+
+ // 获取分页参数
+ page := 1
+ pageSize := 20
+
+ if c.Query("page") != "" {
+ if p, err := strconv.Atoi(c.Query("page")); err == nil {
+ page = p
+ }
+ }
+
+ if c.Query("pageSize") != "" {
+ if ps, err := strconv.Atoi(c.Query("pageSize")); err == nil {
+ pageSize = ps
+ }
+ }
+
+ // 获取文章的访问记录
+ logs, total, err := repositories.GetPostAccessLogs(postID, page, pageSize)
+ if err != nil {
+ utils.ServerError(c, err)
+ return
+ }
+
+ // 构建响应
+ var logList []gin.H
+ for _, log := range logs {
+ logList = append(logList, gin.H{
+ "id": log.ID,
+ "ip": log.IP,
+ "userAgent": log.UserAgent,
+ "path": log.Path,
+ "method": log.Method,
+ "statusCode": log.StatusCode,
+ "responseTime": log.ResponseTime,
+ "region": log.Region,
+ "createdAt": time.Unix(log.CreatedAt, 0).Format("2006-01-02 15:04:05"),
+ })
+ }
+
+ if logList == nil {
+ logList = []gin.H{}
+ }
+
+ res := gin.H{
+ "list": logList,
+ "total": total,
+ "page": page,
+ "size": pageSize,
+ }
+
+ utils.Success(c, res)
+}
diff --git a/server/main.go b/server/main.go
index a314a5f..08e2029 100644
--- a/server/main.go
+++ b/server/main.go
@@ -168,6 +168,10 @@ func main() {
// 操作日志管理
authAdmin.GET("/operation-logs", middleware.PermissionMiddleware("operation_logs", "read"), handlers.AdminGetOperationLogs)
+ // 访问日志管理
+ authAdmin.GET("/access-logs", middleware.PermissionMiddleware("operation_logs", "read"), handlers.AdminGetAccessLogs)
+ authAdmin.GET("/posts/:id/access-logs", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPostAccessLogs)
+
// 仪表盘数据
authAdmin.GET("/dashboard/activities", middleware.PermissionMiddleware("dashboard", "read"), handlers.AdminGetRecentActivities)
diff --git a/server/middleware/log.go b/server/middleware/log.go
index caa80b6..f67a47e 100644
--- a/server/middleware/log.go
+++ b/server/middleware/log.go
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/repositories"
+ "github.com/niangaodev/art-code/utils"
)
// OperationLogMiddleware 操作日志中间件
@@ -46,11 +47,16 @@ func OperationLogMiddleware() gin.HandlerFunc {
username, _ := c.Get("username")
+ // 获取IP归属地
+ ip := c.ClientIP()
+ region := utils.GetRegion(ip)
+
// 构建操作日志
operationLog := &models.OperationLog{
UserID: userID.(uint),
Username: username.(string),
- IP: c.ClientIP(),
+ IP: ip,
+ Region: region,
Path: c.Request.URL.Path,
Method: c.Request.Method,
Params: string(requestBody),
diff --git a/server/models/operation_log.go b/server/models/operation_log.go
index 6cb431d..19fb6f3 100644
--- a/server/models/operation_log.go
+++ b/server/models/operation_log.go
@@ -12,6 +12,7 @@ type OperationLog struct {
UserID uint `json:"userId" gorm:"column:user_id;index"`
Username string `json:"username" gorm:"column:username"`
IP string `json:"ip" gorm:"column:ip"`
+ Region string `json:"region" gorm:"column:region"` // IP归属地
Path string `json:"path" gorm:"column:path;index"`
Method string `json:"method" gorm:"column:method"`
Params string `json:"params" gorm:"column:params;type:text"`
@@ -43,6 +44,7 @@ type OperationLogResponse struct {
UserID uint `json:"userId"`
Username string `json:"username"`
IP string `json:"ip"`
+ Region string `json:"region"` // IP归属地
Path string `json:"path"`
Method string `json:"method"`
Params string `json:"params"`
diff --git a/server/repositories/log_repository.go b/server/repositories/log_repository.go
index dbaaf63..1b8f921 100644
--- a/server/repositories/log_repository.go
+++ b/server/repositories/log_repository.go
@@ -2,6 +2,8 @@ package repositories
import (
// "log"
+ "fmt"
+ "strings"
"time"
"github.com/niangaodev/art-code/config"
@@ -116,22 +118,79 @@ func GetDailyUV(startDate, endDate string) ([]UVTrendData, error) {
return fullResults, nil
}
-// GetUserRegions 获取用户地域分布
+// GetAccessLogs 获取访问日志列表(支持分页和筛选)
+func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64, error) {
+ query := config.DB.Model(&models.AccessLog{}).
+ Where("deleted_at = ?", 0)
+
+ // 如果指定了文章ID,筛选该文章的访问记录
+ if postID != nil {
+ // 路径格式可能是 /api/posts/{id} 或 /blog/{id} 等
+ // 使用 LIKE 匹配包含文章ID的路径
+ query = query.Where("path LIKE ?", fmt.Sprintf("%%/posts/%d%%", *postID))
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var logs []models.AccessLog
+ offset := (page - 1) * pageSize
+ err := query.Order("created_at DESC").
+ Limit(pageSize).
+ Offset(offset).
+ Find(&logs).Error
+
+ return logs, total, err
+}
+
+// GetPostAccessLogs 获取指定文章的访问记录
+func GetPostAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) {
+ return GetAccessLogs(page, pageSize, &postID)
+}
+
+// ExtractProvinceFromRegion 从归属地字符串中提取省份信息
+// 格式: 国家|区域|省份|城市|ISP
+// 例如: 中国|0|浙江|杭州市|联通 -> 浙江
+func ExtractProvinceFromRegion(region string) string {
+ if region == "" || region == "Unknown" || region == "Internal" {
+ return "未知"
+ }
+
+ parts := strings.Split(region, "|")
+ if len(parts) >= 3 {
+ province := strings.TrimSpace(parts[2])
+ if province != "" && province != "0" {
+ // 移除"省"、"市"、"自治区"等后缀,统一格式
+ province = strings.TrimSuffix(province, "省")
+ province = strings.TrimSuffix(province, "市")
+ province = strings.TrimSuffix(province, "自治区")
+ province = strings.TrimSuffix(province, "特别行政区")
+ return province
+ }
+ }
+
+ return "未知"
+}
+
+// GetUserRegions 获取用户地域分布(使用 access_logs 表的 region 字段)
func GetUserRegions(startDate, endDate string) ([]struct {
Region string
Count int
}, error) {
- query := config.DB.Model(&models.UserAccessLog{}).
- Select("COALESCE(NULLIF(user_location, ''), 'Unknown') as region, COUNT(DISTINCT user_ip) as count")
+ query := config.DB.Model(&models.AccessLog{}).
+ Select("COALESCE(NULLIF(region, ''), 'Unknown') as region, COUNT(DISTINCT ip) as count").
+ Where("deleted_at = ?", 0)
if startDate != "" {
startUnix := parseDateToUnix(startDate, false)
- query = query.Where("access_time >= ?", startUnix)
+ query = query.Where("created_at >= ?", startUnix)
}
if endDate != "" {
endUnix := parseDateToUnix(endDate, true)
- query = query.Where("access_time <= ?", endUnix)
+ query = query.Where("created_at <= ?", endUnix)
}
var results []struct {
@@ -140,8 +199,43 @@ func GetUserRegions(startDate, endDate string) ([]struct {
}
err := query.Group("region").
Order("count DESC").
- Limit(20).
+ Limit(50).
Scan(&results).Error
- return results, err
+ if err != nil {
+ return nil, err
+ }
+
+ // 解析归属地,提取省份信息并聚合
+ provinceMap := make(map[string]int)
+ for _, r := range results {
+ province := ExtractProvinceFromRegion(r.Region)
+ provinceMap[province] += r.Count
+ }
+
+ // 转换为结果格式
+ var finalResults []struct {
+ Region string
+ Count int
+ }
+ for province, count := range provinceMap {
+ finalResults = append(finalResults, struct {
+ Region string
+ Count int
+ }{
+ Region: province,
+ Count: count,
+ })
+ }
+
+ // 按数量排序
+ for i := 0; i < len(finalResults)-1; i++ {
+ for j := i + 1; j < len(finalResults); j++ {
+ if finalResults[i].Count < finalResults[j].Count {
+ finalResults[i], finalResults[j] = finalResults[j], finalResults[i]
+ }
+ }
+ }
+
+ return finalResults, nil
}
diff --git a/server/repositories/operation_log_repository.go b/server/repositories/operation_log_repository.go
index 2f8d09a..d14e6b3 100644
--- a/server/repositories/operation_log_repository.go
+++ b/server/repositories/operation_log_repository.go
@@ -56,6 +56,7 @@ func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogRes
UserID: log.UserID,
Username: log.Username,
IP: log.IP,
+ Region: log.Region,
Path: log.Path,
Method: log.Method,
Params: log.Params,
diff --git a/server/utils/ip.go b/server/utils/ip.go
index 1bc28bb..48c4af4 100644
--- a/server/utils/ip.go
+++ b/server/utils/ip.go
@@ -13,6 +13,7 @@ import (
var (
searcher *xdb.Searcher
once sync.Once
+ mu sync.RWMutex // 保护 searcher 的并发访问
)
// InitIP2Region 初始化 ip2region
@@ -52,7 +53,9 @@ func InitIP2Region(dbPath string) {
// 如果最终路径为空,说明找不到文件
if finalPath == "" {
log.Printf("IP2Region database file not found. Region lookup will be disabled.")
+ mu.Lock()
searcher = nil
+ mu.Unlock()
return
}
@@ -60,16 +63,26 @@ func InitIP2Region(dbPath string) {
cBuff, err := xdb.LoadContentFromFile(finalPath)
if err != nil {
log.Printf("Failed to load ip2region.xdb from %s: %v. Region lookup will be disabled.", finalPath, err)
+ mu.Lock()
searcher = nil
+ mu.Unlock()
return
}
- searcher, err = xdb.NewWithBuffer(nil, cBuff)
+ newSearcher, err := xdb.NewWithBuffer(nil, cBuff)
if err != nil {
log.Printf("Failed to create searcher: %v", err)
+ mu.Lock()
searcher = nil
+ mu.Unlock()
return
}
+
+ // 使用写锁设置 searcher
+ mu.Lock()
+ searcher = newSearcher
+ mu.Unlock()
+
log.Printf("IP2Region loaded successfully from %s", finalPath)
})
}
@@ -84,7 +97,13 @@ func GetRegion(ip string) string {
}
}()
- if searcher == nil {
+ // 使用读锁保护并发访问
+ mu.RLock()
+ s := searcher
+ mu.RUnlock()
+
+ // 再次检查 searcher 是否为 nil
+ if s == nil {
return "Unknown"
}
@@ -93,8 +112,25 @@ func GetRegion(ip string) string {
return "Internal"
}
- region, err := searcher.SearchByStr(ip)
+ // 再次检查 searcher 是否为 nil(防止在检查后、调用前被设置为 nil)
+ mu.RLock()
+ s = searcher
+ mu.RUnlock()
+
+ if s == nil {
+ return "Unknown"
+ }
+
+ // 使用 defer recover 保护 SearchByStr 调用
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("Panic in searcher.SearchByStr for IP %s: %v", ip, r)
+ }
+ }()
+
+ region, err := s.SearchByStr(ip)
if err != nil {
+ log.Printf("Error searching region for IP %s: %v", ip, err)
return "Unknown"
}
return region