feat: 管理后台IP归属地——移植ip2region离线库,登录日志/访问埋点/看板最近访问统一显示归属地

This commit is contained in:
Hermes Agent
2026-09-16 07:45:23 +08:00
parent c5e12c67fa
commit c549eb532d
6 changed files with 74 additions and 31 deletions
+3
View File
@@ -48,6 +48,9 @@
<template #default="{ row }">{{ row.method || '-' }}</template> <template #default="{ row }">{{ row.method || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="ip" label="IP" width="120" /> <el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="归属地" width="110">
<template #default="{ row }">{{ row.ipLoc || '—' }}</template>
</el-table-column>
<el-table-column label="结果 / 路径" min-width="150" show-overflow-tooltip> <el-table-column label="结果 / 路径" min-width="150" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.kind === 'login'"> <template v-if="row.kind === 'login'">
+2 -1
View File
@@ -8,7 +8,8 @@
<el-table :data="list" stripe v-loading="loading" size="small"> <el-table :data="list" stripe v-loading="loading" size="small">
<template v-if="tab === 'login'"> <template v-if="tab === 'login'">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column> <el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="140"><template #default="{ row }">{{ row.userName || '未知用户' }}</template></el-table-column> <el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userName || '未知用户' }}</template></el-table-column>
<el-table-column label="归属地" width="110"><template #default="{ row }">{{ row.ipLoc || '—' }}</template></el-table-column>
<el-table-column prop="type" label="类型" width="80" /> <el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="method" label="方式" width="120" /> <el-table-column prop="method" label="方式" width="120" />
<el-table-column prop="ip" label="IP" width="120" /> <el-table-column prop="ip" label="IP" width="120" />
+9 -2
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.29.4", "moment": "^2.29.4",
"mongoose": "^8.0.3", "mongoose": "^8.0.3",
@@ -1096,7 +1097,6 @@
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -2819,6 +2819,14 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip2region": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ip2region/-/ip2region-2.3.0.tgz",
"integrity": "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==",
"peerDependencies": {
"@types/node": "*"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5510,7 +5518,6 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+1
View File
@@ -27,6 +27,7 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.29.4", "moment": "^2.29.4",
"mongoose": "^8.0.3", "mongoose": "^8.0.3",
+19 -14
View File
@@ -2,6 +2,7 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const mongoose = require('mongoose') const mongoose = require('mongoose')
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger, VisitLog } = require('../models') const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger, VisitLog } = require('../models')
const { ipLocation } = require('../utils/iploc')
const { authMiddleware, requirePermission } = require('../middleware/auth') const { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const logger = require('../utils/logger') const logger = require('../utils/logger')
@@ -74,6 +75,7 @@ router.get('/visits', requirePermission('dashboard:read'), async (req, res) => {
VisitLog.countDocuments({}), VisitLog.countDocuments({}),
VisitLog.find({}).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(), VisitLog.find({}).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
]) ])
items.forEach(v => { v.ipLoc = ipLocation(v.ip) })
return ApiResponse.success(res, { items, total, page, limit, pagination: { total } }) return ApiResponse.success(res, { items, total, page, limit, pagination: { total } })
} catch (error) { } catch (error) {
logger.error('访问日志查询失败:', error) logger.error('访问日志查询失败:', error)
@@ -1098,6 +1100,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
const formattedLogs = logs.map(log => ({ const formattedLogs = logs.map(log => ({
id: log._id, id: log._id,
userName: log.userId?.nickName || '未知用户', userName: log.userId?.nickName || '未知用户',
ipLoc: ipLocation(log.ip),
userAvatar: log.userId?.avatarUrl || '', userAvatar: log.userId?.avatarUrl || '',
type: log.type, type: log.type,
method: log.method, method: log.method,
@@ -1221,18 +1224,8 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' } const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
const ai = aiStatsRaw[0] || { used: 0, users: 0 } const ai = aiStatsRaw[0] || { used: 0, users: 0 }
return ApiResponse.success(res, { // 合并最近访问记录(登录 + 访问埋点),统一附 IP 归属地
stats: { const merged = [
totalUsers, newUsers7d,
totalKnowledge, approvedKnowledge, withQuiz,
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks,
totalOrders, paidOrders,
totalLits, litUsers: litUsers.length,
aiChatUsed: ai.used, aiChatUsers: ai.users,
},
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
recentLogins: [
// 登录记录(显示昵称)
...recentLogins.map(l => ({ ...recentLogins.map(l => ({
kind: 'login', kind: 'login',
createdAt: l.createdAt, createdAt: l.createdAt,
@@ -1244,7 +1237,6 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
success: l.success, success: l.success,
failReason: l.failReason, failReason: l.failReason,
})), })),
// 访问埋点(H5 / 小程序 / 直接访问)
...recentVisits.map(v => ({ ...recentVisits.map(v => ({
kind: 'visit', kind: 'visit',
createdAt: v.createdAt, createdAt: v.createdAt,
@@ -1258,7 +1250,20 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
})), })),
] ]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10), .slice(0, 10)
merged.forEach(r => { r.ipLoc = ipLocation(r.ip) })
return ApiResponse.success(res, {
stats: {
totalUsers, newUsers7d,
totalKnowledge, approvedKnowledge, withQuiz,
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks,
totalOrders, paidOrders,
totalLits, litUsers: litUsers.length,
aiChatUsed: ai.used, aiChatUsers: ai.users,
},
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
recentLogins: merged,
}) })
} catch (error) { } catch (error) {
logger.error('获取增强看板失败:', error) logger.error('获取增强看板失败:', error)
+26
View File
@@ -0,0 +1,26 @@
const mod = require('ip2region')
const IP2Region = mod.default ?? mod
let query = null
/** IP 归属地(离线库 ip2region,城市级;内网/无效 IP 返回空) */
function ipLocation(ip) {
if (!ip) return ''
try {
if (!query) query = new IP2Region()
const r = query.search(ip) || {}
if (r.city === '内网IP') return '内网'
// 中国省份缺失时附带运营商;境外显示国家
if (r.country === '中国') {
const prov = (r.province || '').replace(/省|市|自治区|特别行政区|壮族|回族|维吾尔/g, '')
const city = (r.city || '').replace(/市$/, '')
const base = [prov, city].filter(Boolean).join(' ')
return base ? base : (r.isp ? `中国 ${r.isp}` : '中国')
}
return r.country || ''
} catch (e) {
return ''
}
}
module.exports = { ipLocation }