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
+33 -28
View File
@@ -2,6 +2,7 @@ const express = require('express')
const router = express.Router()
const mongoose = require('mongoose')
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 ApiResponse = require('../utils/response')
const logger = require('../utils/logger')
@@ -74,6 +75,7 @@ router.get('/visits', requirePermission('dashboard:read'), async (req, res) => {
VisitLog.countDocuments({}),
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 } })
} catch (error) {
logger.error('访问日志查询失败:', error)
@@ -1098,6 +1100,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
const formattedLogs = logs.map(log => ({
id: log._id,
userName: log.userId?.nickName || '未知用户',
ipLoc: ipLocation(log.ip),
userAvatar: log.userId?.avatarUrl || '',
type: log.type,
method: log.method,
@@ -1221,6 +1224,35 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
const ai = aiStatsRaw[0] || { used: 0, users: 0 }
// 合并最近访问记录(登录 + 访问埋点),统一附 IP 归属地
const merged = [
...recentLogins.map(l => ({
kind: 'login',
createdAt: l.createdAt,
userName: l.userId?.nickName || l.openid,
openid: l.openid,
type: l.type === 'register' ? '注册' : '登录',
method: l.method === 'wechat_miniapp' ? '微信小程序' : (l.method || ''),
ip: l.ip,
success: l.success,
failReason: l.failReason,
})),
...recentVisits.map(v => ({
kind: 'visit',
createdAt: v.createdAt,
userName: v.source,
openid: '',
type: '访问',
method: [v.os, v.browser].filter(Boolean).join(' / ') || v.device || '',
ip: v.ip,
path: v.path,
success: true,
})),
]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10)
merged.forEach(r => { r.ipLoc = ipLocation(r.ip) })
return ApiResponse.success(res, {
stats: {
totalUsers, newUsers7d,
@@ -1231,34 +1263,7 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
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 => ({
kind: 'login',
createdAt: l.createdAt,
userName: l.userId?.nickName || l.openid,
openid: l.openid,
type: l.type === 'register' ? '注册' : '登录',
method: l.method === 'wechat_miniapp' ? '微信小程序' : (l.method || ''),
ip: l.ip,
success: l.success,
failReason: l.failReason,
})),
// 访问埋点(H5 / 小程序 / 直接访问)
...recentVisits.map(v => ({
kind: 'visit',
createdAt: v.createdAt,
userName: v.source,
openid: '',
type: '访问',
method: [v.os, v.browser].filter(Boolean).join(' / ') || v.device || '',
ip: v.ip,
path: v.path,
success: true,
})),
]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10),
recentLogins: merged,
})
} catch (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 }