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 @@ + + + + + diff --git a/client/src/components/admin/AdminLayout.vue b/client/src/components/admin/AdminLayout.vue index e8cb873..c5eb9db 100644 --- a/client/src/components/admin/AdminLayout.vue +++ b/client/src/components/admin/AdminLayout.vue @@ -263,7 +263,8 @@ const menuItems = ref([ 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 @@ + + + + + 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 ? '已发布' : '草稿' }} + + +
@@ -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