Skip to content

Commit 9ab6564

Browse files
committed
优化
1 parent 7eef458 commit 9ab6564

File tree

11 files changed

+1313
-42
lines changed

11 files changed

+1313
-42
lines changed

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@
146146
<groupId>io.zipkin.reporter2</groupId>
147147
<artifactId>zipkin-reporter-brave</artifactId>
148148
</dependency>
149+
<!-- IP地理位置解析 -->
150+
<dependency>
151+
<groupId>org.lionsoul</groupId>
152+
<artifactId>ip2region</artifactId>
153+
<version>2.7.0</version>
154+
</dependency>
149155
</dependencies>
150156

151157
<build>

src/main/java/com/layor/tinyflow/service/ClickRecorderService.java

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.layor.tinyflow.service;
22

33
import com.layor.tinyflow.config.RabbitMQConfig;
4+
import com.layor.tinyflow.entity.ClickEvent;
45
import com.layor.tinyflow.entity.ClickMessage;
56
import com.layor.tinyflow.entity.DailyClick;
67
import com.layor.tinyflow.repository.DailyClickRepository;
@@ -17,6 +18,13 @@
1718
import org.springframework.beans.factory.annotation.Value;
1819

1920
import java.time.LocalDate;
21+
import java.time.LocalDateTime;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.concurrent.ConcurrentHashMap;
25+
import java.util.concurrent.ConcurrentLinkedQueue;
26+
import java.util.concurrent.ThreadLocalRandom;
27+
import java.util.concurrent.atomic.AtomicLong;
2028

2129
@Service
2230
@Slf4j
@@ -32,15 +40,21 @@ public class ClickRecorderService {
3240
private org.springframework.data.redis.core.StringRedisTemplate redisTemplate;
3341
@Autowired
3442
private RabbitTemplate rabbitTemplate;
43+
@Autowired(required = false)
44+
private IpLocationService ipLocationService;
3545

3646
@Value("${clicks.mode:mq}")
3747
private String counterMode;
3848

3949
@Value("${events.sampleRate:0.0}")
4050
private double sampleRate;
4151

42-
private final java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicLong> localTotal = new java.util.concurrent.ConcurrentHashMap<>();
43-
private final java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicLong> localDay = new java.util.concurrent.ConcurrentHashMap<>();
52+
private final ConcurrentHashMap<String, AtomicLong> localTotal = new ConcurrentHashMap<>();
53+
private final ConcurrentHashMap<String, AtomicLong> localDay = new ConcurrentHashMap<>();
54+
55+
// 详细事件批量缓冲队列
56+
private final ConcurrentLinkedQueue<ClickEvent> eventBuffer = new ConcurrentLinkedQueue<>();
57+
private static final int EVENT_BATCH_SIZE = 100;
4458

4559
/**
4660
* 记录点击事件
@@ -53,36 +67,94 @@ public class ClickRecorderService {
5367
@Async
5468
public void recordClick(String shortCode) {
5569
// 所有模式都先写本地内存(最快)
56-
localTotal.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong())
70+
localTotal.computeIfAbsent(shortCode, k -> new AtomicLong())
5771
.incrementAndGet();
58-
localDay.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong())
72+
localDay.computeIfAbsent(shortCode, k -> new AtomicLong())
5973
.incrementAndGet();
6074

6175
log.debug("[LOCAL] Click recorded: {}", shortCode);
6276
}
6377

78+
/**
79+
* 记录详细点击事件(包含 IP 地理位置解析)
80+
* 使用批量缓冲队列,定时批量写入数据库
81+
*/
6482
@Async
6583
public void recordClickEvent(String shortCode, String referer, String ua, String ip, String host, String device) {
6684
if (sampleRate <= 0.0) return;
6785
if (sampleRate < 1.0) {
68-
if (java.util.concurrent.ThreadLocalRandom.current().nextDouble() >= sampleRate) return;
86+
if (ThreadLocalRandom.current().nextDouble() >= sampleRate) return;
87+
}
88+
89+
// IP 地理位置解析
90+
String city = "";
91+
String country = "";
92+
if (ipLocationService != null && ip != null && !ip.isEmpty()) {
93+
try {
94+
city = ipLocationService.getCity(ip);
95+
country = ipLocationService.getCountry(ip);
96+
} catch (Exception e) {
97+
log.debug("[EVENT] IP location parse failed for {}: {}", ip, e.getMessage());
98+
}
6999
}
70-
// 封装
71-
com.layor.tinyflow.entity.ClickEvent ev = com.layor.tinyflow.entity.ClickEvent.builder()
100+
101+
// 构建事件对象
102+
ClickEvent ev = ClickEvent.builder()
72103
.shortCode(shortCode)
73-
.ts(java.time.LocalDateTime.now())
104+
.ts(LocalDateTime.now())
74105
.referer(referer)
75106
.ua(ua)
76107
.ip(ip)
77108
.sourceHost(host)
78109
.deviceType(device)
79-
.city("")
80-
.country("")
110+
.city(city)
111+
.country(country)
81112
.build();
82-
// 保存
83-
clickEventRepository.save(ev);
113+
114+
// 加入批量缓冲队列
115+
eventBuffer.offer(ev);
116+
117+
// 缓冲区达到阈值时触发批量写入
118+
if (eventBuffer.size() >= EVENT_BATCH_SIZE) {
119+
flushEventBuffer();
120+
}
121+
122+
log.debug("[EVENT] Buffered click event: code={}, city={}, country={}", shortCode, city, country);
84123
}
85124

125+
/**
126+
* 定时刷新事件缓冲区(5秒一次)
127+
*/
128+
@Scheduled(fixedDelay = 5000)
129+
@Transactional
130+
public void flushEventBuffer() {
131+
if (eventBuffer.isEmpty()) return;
132+
133+
long startTime = System.currentTimeMillis();
134+
List<ClickEvent> batch = new ArrayList<>();
135+
136+
// 每次最多处理 500 条
137+
int count = 0;
138+
while (!eventBuffer.isEmpty() && count < 500) {
139+
ClickEvent ev = eventBuffer.poll();
140+
if (ev != null) {
141+
batch.add(ev);
142+
count++;
143+
}
144+
}
145+
146+
if (!batch.isEmpty()) {
147+
try {
148+
clickEventRepository.saveAll(batch);
149+
long duration = System.currentTimeMillis() - startTime;
150+
log.info("[EVENT FLUSH] Saved {} click events to DB, duration={}ms", batch.size(), duration);
151+
} catch (Exception e) {
152+
log.error("[EVENT FLUSH ERROR] Failed to save click events: {}", e.getMessage(), e);
153+
// 失败时重新放回队列
154+
eventBuffer.addAll(batch);
155+
}
156+
}
157+
}
86158

87159
/**
88160
* 第一阶段:定期快照到 Redis(10 秒一次)
@@ -178,4 +250,4 @@ private long toLong(Object value) {
178250
if (value instanceof Number) return ((Number) value).longValue();
179251
try { return Long.parseLong(String.valueOf(value)); } catch (Exception ex) { return 0L; }
180252
}
181-
}
253+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.layor.tinyflow.service;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Service;
6+
7+
/**
8+
* IP 地理位置解析服务
9+
* 简化版本:暂时返回空值,后续可集成 ip2region 或第三方 API
10+
*/
11+
@Service
12+
@Slf4j
13+
public class IpLocationService {
14+
15+
@PostConstruct
16+
public void init() {
17+
log.info("[IpLocation] IP location service initialized (simplified mode)");
18+
}
19+
20+
/**
21+
* 解析 IP 地址获取地理位置
22+
* @param ip IP 地址
23+
* @return 格式: "国家|区域|省份|城市|ISP"
24+
*/
25+
public String search(String ip) {
26+
if (ip == null || ip.isEmpty()) {
27+
return null;
28+
}
29+
// 过滤私有/本地 IP
30+
if (isPrivateIp(ip)) {
31+
return "内网|内网|内网|内网|内网";
32+
}
33+
// 简化实现:返回空,后续可集成 ip2region
34+
return null;
35+
}
36+
37+
/**
38+
* 解析城市名
39+
*/
40+
public String getCity(String ip) {
41+
String region = search(ip);
42+
if (region == null) return "";
43+
// 格式: 国家|区域|省份|城市|ISP
44+
String[] parts = region.split("\\|");
45+
if (parts.length >= 4) {
46+
String city = parts[3];
47+
// 如果城市为 "0" 或空,返回省份
48+
if ("0".equals(city) || city.isEmpty()) {
49+
return parts.length >= 3 ? parts[2] : "";
50+
}
51+
return city;
52+
}
53+
return "";
54+
}
55+
56+
/**
57+
* 解析国家名
58+
*/
59+
public String getCountry(String ip) {
60+
String region = search(ip);
61+
if (region == null) return "";
62+
String[] parts = region.split("\\|");
63+
if (parts.length >= 1) {
64+
String country = parts[0];
65+
return "0".equals(country) ? "" : country;
66+
}
67+
return "";
68+
}
69+
70+
/**
71+
* 判断是否为私有 IP
72+
*/
73+
private boolean isPrivateIp(String ip) {
74+
if (ip == null) return true;
75+
return ip.startsWith("10.") ||
76+
ip.startsWith("172.16.") || ip.startsWith("172.17.") ||
77+
ip.startsWith("172.18.") || ip.startsWith("172.19.") ||
78+
ip.startsWith("172.20.") || ip.startsWith("172.21.") ||
79+
ip.startsWith("172.22.") || ip.startsWith("172.23.") ||
80+
ip.startsWith("172.24.") || ip.startsWith("172.25.") ||
81+
ip.startsWith("172.26.") || ip.startsWith("172.27.") ||
82+
ip.startsWith("172.28.") || ip.startsWith("172.29.") ||
83+
ip.startsWith("172.30.") || ip.startsWith("172.31.") ||
84+
ip.startsWith("192.168.") ||
85+
ip.equals("127.0.0.1") ||
86+
ip.equals("localhost") ||
87+
ip.equals("::1");
88+
}
89+
}

src/main/resources/application.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ management:
131131
endpoint: http://localhost:9411/api/v2/spans
132132

133133
events:
134-
sampleRate: 0.0
134+
sampleRate: 1.0 # 100%记录详细点击事件,支持分布图统计
135135

136136
# 点击计数模式:使用本地内存 + Redis 快照方案
137137
# 方案 C:第一阶段(10s)本地→Redis快照,第二阶段(60s)Redis→数据库
Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,51 @@
11
<template>
22
<div :class="wrapperClass">
3-
<div v-for="n in count" :key="n" class="bg-gray-200 animate-pulse rounded" :style="{height: height+'px'}"></div>
3+
<div v-for="n in count" :key="n" :class="skeletonClass" :style="skeletonStyle"></div>
44
</div>
55
</template>
66

77
<script setup>
8-
const props = defineProps({ count: { type: Number, default: 1 }, height: { type: Number, default: 160 }, vertical: { type: Boolean, default: true } })
9-
const wrapperClass = props.vertical ? 'space-y-2' : 'flex gap-2'
8+
import { computed } from 'vue'
9+
10+
const props = defineProps({
11+
count: { type: Number, default: 1 },
12+
height: { type: Number, default: 160 },
13+
width: { type: String, default: '100%' },
14+
vertical: { type: Boolean, default: true },
15+
variant: { type: String, default: 'default' } // 'default', 'card', 'text', 'circle'
16+
})
17+
18+
const wrapperClass = computed(() => props.vertical ? 'space-y-2' : 'flex gap-2')
19+
20+
const skeletonClass = computed(() => {
21+
const baseClass = 'animate-pulse'
22+
const variantClass = {
23+
'default': 'bg-gray-200 rounded',
24+
'card': 'bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 rounded-lg shadow-sm',
25+
'text': 'bg-gray-200 rounded',
26+
'circle': 'bg-gray-200 rounded-full'
27+
}
28+
return `${baseClass} ${variantClass[props.variant] || variantClass.default}`
29+
})
30+
31+
const skeletonStyle = computed(() => ({
32+
height: props.variant === 'circle' ? props.height + 'px' : props.height + 'px',
33+
width: props.variant === 'circle' ? props.height + 'px' : props.width
34+
}))
1035
</script>
1136

12-
<style scoped></style>
37+
<style scoped>
38+
@keyframes shimmer {
39+
0% { background-position: -468px 0; }
40+
100% { background-position: 468px 0; }
41+
}
42+
43+
.animate-pulse {
44+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
45+
}
46+
47+
@keyframes pulse {
48+
0%, 100% { opacity: 1; }
49+
50% { opacity: 0.5; }
50+
}
51+
</style>

0 commit comments

Comments
 (0)