feat: 管理后台IP归属地——移植ip2region离线库,登录日志/访问埋点/看板最近访问统一显示归属地
This commit is contained in:
@@ -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'">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Generated
+9
-2
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
Reference in New Issue
Block a user