Implementing Visitor Logging with Spring Boot and Vue: PV/UV Tracking Component
This tutorial demonstrates how to record and display website visitor statistics by using a Spring Boot backend with an IP‑based visitor identifier, a custom @VisitLog annotation, a MySQL visit‑log table, and a Vue front‑end component that shows PV and UV counts.
1. Introduction
The author explains the motivation for adding visitor statistics to a small website, defines PV (page view) and UV (unique visitor), and outlines the overall approach of logging each request based on the visitor's IP address.
2. User Identity Identification
Instead of implementing full login, the solution uses the visitor's IP address and the ip2region library to resolve geographic information. The core Java utility class AddressUtil queries the local ip2region.xdb file and returns the city name.
package com.example.springbootip.util;
import org.apache.commons.io.FileUtils;
import org.lionsoul.ip2region.xdb.Searcher;
import java.io.File;
import java.text.MessageFormat;
import java.util.Objects;
public class AddressUtil {
private static final String TEMP_FILE_DIR = "/home/admin/app/";
public static String getCityInfo(String ip) {
try {
String dbPath = Objects.requireNonNull(AddressUtil.class.getResource("/ip2region/ip2region.xdb")).getPath();
File file = new File(dbPath);
if (!file.exists()) {
dbPath = TEMP_FILE_DIR + "ip.db";
System.out.println(MessageFormat.format("当前目录为:[{0}]", dbPath));
file = new File(dbPath);
FileUtils.copyInputStreamToFile(Objects.requireNonNull(AddressUtil.class.getClassLoader().getResourceAsStream("classpath:ip2region/ip2region.xdb")), file);
}
Searcher searcher = Searcher.newWithFileOnly(dbPath);
return searcher.searchByStr(ip);
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
public static void main(String[] args) {
System.out.println(getCityInfo("1.2.3.4"));
}
}3. Backend Implementation
(1) Visit Log Table Design
-- `summo-sbmy`.t_sbmy_visit_log definition
CREATE TABLE `t_sbmy_visit_log` (
`id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '物理主键',
`device_type` varchar(256) DEFAULT NULL COMMENT '设备类型,手机还是电脑',
`ip` varchar(256) DEFAULT NULL COMMENT '访问',
`address` varchar(256) DEFAULT NULL COMMENT 'IP地址',
`time` int DEFAULT NULL COMMENT '耗时',
`method` varchar(2048) DEFAULT NULL COMMENT '调用方法',
`params` text COMMENT '参数',
`gmt_create` datetime DEFAULT NULL COMMENT '创建时间',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新时间',
`creator_id` bigint DEFAULT NULL COMMENT '创建人',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;(2) @VisitLog Annotation
package com.summo.sbmy.aspect;
/**
* 访问标识注解
*/
public @interface VisitLog {
}(3) VisitLogAspect
package com.summo.sbmy.aspect.visit;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.summo.sbmy.common.util.AddressUtil;
import com.summo.sbmy.common.util.HttpContextUtil;
import com.summo.sbmy.common.util.IpUtil;
import com.summo.sbmy.dao.entity.SbmyVisitLogDO;
import com.summo.sbmy.dao.repository.SbmyVisitLogRepository;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import static com.summo.sbmy.common.util.DeviceUtil.isFromMobile;
@Slf4j
@Aspect
@Component
public class VisitLogAspect {
@Autowired
private SbmyVisitLogRepository sbmyVisitLogRepository;
@Pointcut("@annotation(com.summo.sbmy.aspect.visit.Log)")
public void pointcut() {}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
String ip = IpUtil.getIpAddr(request);
String address = AddressUtil.getAddress(ip);
SbmyVisitLogDO sbmyVisitLogDO = SbmyVisitLogDO.builder()
.deviceType(isFromMobile(request) ? "手机" : "电脑")
.method(className + "." + methodName + "()")
.ip(ip)
.address(AddressUtil.getAddress(address))
.build();
Object[] args = joinPoint.getArgs();
LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
String[] paramNames = discoverer.getParameterNames(method);
if (args != null && paramNames != null) {
Map
paramMap = new LinkedHashMap<>();
for (int i = 0; i < paramNames.length; i++) {
if (args[i] instanceof HttpServletRequest || args[i] instanceof HttpServletResponse) continue;
paramMap.put(paramNames[i], args[i]);
}
String paramsJson = JSON.toJSONString(paramMap);
sbmyVisitLogDO.setParams(paramsJson);
}
long beginTime = System.currentTimeMillis();
Object proceed = joinPoint.proceed();
long end = System.currentTimeMillis();
sbmyVisitLogDO.setTime((int) (end - beginTime));
sbmyVisitLogRepository.save(sbmyVisitLogDO);
return proceed;
}
}4. Frontend Implementation
(1) VisitorLog Component (Vue)
<template>
<div class="stats-card-container">
<el-card class="stats-card-main">
<div class="stats-section">
<div class="stats-value-main">{{ statsData.todayPv }}</div>
<div class="stats-label-main">今日 PV</div>
</div>
<div class="stats-section stats-others">
<div class="stats-item">
<div class="stats-value-small">{{ statsData.todayUv }}</div>
<div class="stats-label-small">今日 UV</div>
</div>
<div class="stats-item">
<div class="stats-value-small">{{ statsData.allPv }}</div>
<div class="stats-label-small">总 PV</div>
</div>
<div class="stats-item">
<div class="stats-value-small">{{ statsData.allUv }}</div>
<div class="stats-label-small">总 UV</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import apiService from "@/config/apiService.js";
export default {
name: "VisitorLog",
data() {
return {
statsData: { todayPv: 0, todayUv: 0, allPv: 0, allUv: 0 }
};
},
created() {
this.fetchVisitorCount();
this.startPolling();
},
beforeDestroy() {
this.stopPolling();
},
methods: {
fetchVisitorCount() {
apiService.get("/welcome/queryVisitorCount")
.then(res => { this.statsData = res.data.data; })
.catch(error => { console.error(error); });
},
startPolling() {
this.polling = setInterval(this.fetchVisitorCount, 1000 * 60 * 60);
},
stopPolling() {
clearInterval(this.polling);
}
}
};
</script>
<style scoped>
/* Styles omitted for brevity */
</style>(2) Integration into App.vue
<template>
<div id="app">
<el-row :gutter="10">
<el-col :span="20">
<el-row :gutter="10">
<el-col :span="6" v-for="(board, index) in hotBoards" :key="index">
<hot-search-board :title="board.title" :icon="board.icon" :fetch-url="board.fetchUrl" :type="board.type" />
</el-col>
</el-row>
</el-col>
<el-col :span="4">
<visitor-log />
</el-col>
</el-row>
</div>
</template>
<script>
import HotSearchBoard from "@/components/HotSearchBoard.vue";
import VisitorLog from "@/components/VisitorLog.vue";
export default {
name: "App",
components: { HotSearchBoard, VisitorLog },
data() {
return {
hotBoards: [/* board definitions omitted */]
};
}
};
</script>
<style>
#app { /* layout styles omitted */ }
</style>5. Optional Sogou Hot Search Crawler
The article also provides a simple Java job that fetches hot search data from Sogou, parses the JSON response, stores the results in a cache, and persists them to the database.
package com.summo.sbmy.job.sougou;
import com.alibaba.fastjson.*;
import com.google.common.collect.Lists;
import com.summo.sbmy.dao.entity.SbmyHotSearchDO;
import com.summo.sbmy.service.SbmyHotSearchService;
import com.summo.sbmy.service.convert.HotSearchConvert;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Component
public class SougouHotSearchJob {
@Autowired
private SbmyHotSearchService sbmyHotSearchService;
@XxlJob("sougouHotSearchJob")
public ReturnT
hotSearch(String param) throws IOException {
log.info("搜狗热搜爬虫任务开始");
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url("https://go.ie.sogou.com/hot_ranks").build();
Response response = client.newCall(request).execute();
JSONObject jsonObject = JSONObject.parseObject(response.body().string());
JSONArray array = jsonObject.getJSONArray("data");
List
list = Lists.newArrayList();
for (int i = 0; i < array.size(); i++) {
JSONObject obj = array.getJSONObject(i);
SbmyHotSearchDO do_ = SbmyHotSearchDO.builder().hotSearchResource("SOUGOU").build();
do_.setHotSearchId(obj.getString("id"));
do_.setHotSearchTitle(obj.getJSONObject("attributes").getString("title"));
do_.setHotSearchUrl("https://www.sogou.com/web?ie=utf8&query=" + do_.getHotSearchTitle());
do_.setHotSearchHeat(obj.getJSONObject("attributes").getString("num"));
do_.setHotSearchOrder(i + 1);
list.add(do_);
}
if (CollectionUtils.isEmpty(list)) return ReturnT.SUCCESS;
// cache and persist omitted for brevity
log.info("搜狗热搜爬虫任务结束");
return ReturnT.SUCCESS;
}
}Overall, the guide shows how to capture visitor IPs, resolve their locations, store request metadata in a MySQL table via a Spring Boot aspect, and present real‑time PV/UV statistics in a Vue component.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.