Skip to content

Commit 2831208

Browse files
committed
feat(security,click-statistics): 完善权限控制和优化点击统计性能 - 权限控制:PUT/DELETE需认证 - 点击统计三阶段流程优化 - 前端添加登录检查和错误提示 - 修复复制按钮文案显示
1 parent b5804a8 commit 2831208

File tree

4 files changed

+144
-111
lines changed

4 files changed

+144
-111
lines changed

src/main/java/com/layor/tinyflow/config/SecurityConfig.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
5656
.requestMatchers(
5757
"/api/auth/**", // 认证接口(注册、登录)
5858
"/api/redirect/**", // 短链跳转(核心功能)
59-
"/api/shorten", // 创建短链(暂时允许匿名)
60-
"/api/urls", // 查询短链列表(暂时允许匿名)
61-
"/api/urls/**", // 短链相关操作(暂时允许匿名)
62-
"/api/stats/**", // 统计接口(暂时允许匿名)
59+
"/api/shorten", // 创建短链(允许匿名)
60+
"/api/urls", // 查询短链列表(允许匿名)
61+
"/api/stats/**", // 统计接口(允许匿名)
6362
"/actuator/**", // 监控端点
6463
"/error", // 错误页面
6564
"/{shortCode}" // 根路径短链重定向
@@ -68,6 +67,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
6867
// 管理员接口
6968
.requestMatchers("/api/admin/**").hasRole("ADMIN")
7069

70+
// PUT/DELETE 短链相关操作需要认证(修改、删除操作需要登录)
71+
.requestMatchers("PUT", "/api/urls/**").authenticated()
72+
.requestMatchers("DELETE", "/api/urls/**").authenticated()
73+
.requestMatchers("PUT", "/api/*").authenticated()
74+
.requestMatchers("DELETE", "/api/*").authenticated()
75+
7176
// 其他所有接口需要认证
7277
.anyRequest().authenticated()
7378
)

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

Lines changed: 93 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -43,57 +43,22 @@ public class ClickRecorderService {
4343
private final java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicLong> localDay = new java.util.concurrent.ConcurrentHashMap<>();
4444

4545
/**
46-
* 记录点击事件(使用消息队列)
47-
* 优先级:MQ > Local > Redis
46+
* 记录点击事件
47+
* 优先级:Local > Redis Snapshot > MQ
48+
*
49+
* 方案 C:本地内存 + Redis 快照 + 定期持久化
50+
* ├─ 第一阶段(10s):本地计数 → Redis 快照(冷备份)
51+
* └─ 第二阶段(60s):Redis 快照 → 数据库(正式记账)
4852
*/
4953
@Async
5054
public void recordClick(String shortCode) {
51-
// 模式1:消息队列模式(推荐,高可靠)
52-
if ("mq".equalsIgnoreCase(counterMode)) {
53-
ClickMessage message = ClickMessage.builder()
54-
.shortCode(shortCode)
55-
.timestamp(System.currentTimeMillis())
56-
.date(LocalDate.now().toString())
57-
.build();
58-
try {
59-
rabbitTemplate.convertAndSend(
60-
RabbitMQConfig.CLICK_EXCHANGE,
61-
RabbitMQConfig.CLICK_ROUTING_KEY,
62-
message
63-
);
64-
log.debug("[MQ] Click message sent: {}", shortCode);
65-
} catch (Exception e) {
66-
log.error("[MQ ERROR] Failed to send click message for {}: {}", shortCode, e.getMessage());
67-
// 降级到本地模式
68-
localTotal.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong()).incrementAndGet();
69-
localDay.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong()).incrementAndGet();
70-
}
71-
return;
72-
}
73-
74-
// 模式2:本地内存模式
75-
if ("local".equalsIgnoreCase(counterMode)) {
76-
localTotal.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong()).incrementAndGet();
77-
localDay.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong()).incrementAndGet();
78-
return;
79-
}
55+
// 所有模式都先写本地内存(最快)
56+
localTotal.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong())
57+
.incrementAndGet();
58+
localDay.computeIfAbsent(shortCode, k -> new java.util.concurrent.atomic.AtomicLong())
59+
.incrementAndGet();
8060

81-
// 模式3:Redis Pipeline 模式
82-
String totalKey = "tf:clicks:total";
83-
String dayKey = "tf:clicks:day:" + LocalDate.now();
84-
try {
85-
// 使用Pipeline减少网络往返
86-
redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
87-
@Override
88-
public Object execute(org.springframework.data.redis.core.RedisOperations operations) {
89-
operations.opsForHash().increment(dayKey, shortCode, 1);
90-
operations.opsForHash().increment(totalKey, shortCode, 1);
91-
return null;
92-
}
93-
});
94-
} catch (org.springframework.data.redis.RedisConnectionFailureException ex) {
95-
log.error("redis connect failed: {}", ex.getMessage());
96-
}
61+
log.debug("[LOCAL] Click recorded: {}", shortCode);
9762
}
9863

9964
@Async
@@ -119,66 +84,93 @@ public void recordClickEvent(String shortCode, String referer, String ua, String
11984
}
12085

12186

122-
@Scheduled(fixedDelay = 2000)
123-
@Transactional
124-
public void flushCounters() {
125-
if ("local".equalsIgnoreCase(counterMode)) {
126-
for (var e : localTotal.entrySet()) {
127-
String code = e.getKey();
128-
long delta = e.getValue().getAndSet(0);
129-
if (delta > 0) { shortUrlRepository.incrementClickCountBy(code, delta); }
87+
/**
88+
* 第一阶段:定期快照到 Redis(10 秒一次)
89+
* 作用:冷备份,防止服务宕机时丢失数据
90+
*/
91+
@Scheduled(fixedDelay = 10000) // 每 10 秒
92+
public void snapshotToRedis() {
93+
try {
94+
long startTime = System.currentTimeMillis();
95+
int snapshotCount = 0;
96+
97+
// 快照总点击数
98+
for (var entry : localTotal.entrySet()) {
99+
String code = entry.getKey();
100+
long count = entry.getValue().get(); // 只读,不重置
101+
if (count > 0) {
102+
redisTemplate.opsForHash().put("tf:click:snapshot:total", code, String.valueOf(count));
103+
snapshotCount++;
104+
}
130105
}
131-
for (var e : localDay.entrySet()) {
132-
String code = e.getKey();
133-
long delta = e.getValue().getAndSet(0);
134-
if (delta > 0) { dailyClickRepo.incrementClickBy(code, delta); }
106+
107+
// 快照每日点击数
108+
for (var entry : localDay.entrySet()) {
109+
String code = entry.getKey();
110+
long count = entry.getValue().get(); // 只读,不重置
111+
if (count > 0) {
112+
String dayKey = "tf:click:snapshot:day:" + LocalDate.now();
113+
redisTemplate.opsForHash().put(dayKey, code, String.valueOf(count));
114+
snapshotCount++;
115+
}
116+
}
117+
118+
long duration = System.currentTimeMillis() - startTime;
119+
if (snapshotCount > 0) {
120+
log.info("[SNAPSHOT] Saved {} entries to Redis, duration={}ms", snapshotCount, duration);
135121
}
136-
return;
122+
} catch (Exception e) {
123+
log.error("[SNAPSHOT ERROR] Failed to snapshot to Redis: {}", e.getMessage(), e);
137124
}
138-
String totalKey = "tf:clicks:total";
139-
String dayKey = "tf:clicks:day:" + LocalDate.now();
140-
String tmpTotal = totalKey + ":flush:" + System.currentTimeMillis();
141-
String tmpDay = dayKey + ":flush:" + System.currentTimeMillis();
142-
143-
// 使用Pipeline批量重命名和读取,减少网络往返
125+
}
126+
127+
/**
128+
* 第二阶段:定期持久化到数据库(60 秒一次)
129+
* 作用:正式记账,将快照中的数据写入数据库
130+
*/
131+
@Scheduled(fixedDelay = 60000) // 每 60 秒
132+
@Transactional
133+
public void syncFromRedisToDB() {
144134
try {
145-
redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
146-
@Override
147-
public Object execute(org.springframework.data.redis.core.RedisOperations operations) {
148-
try { operations.rename(totalKey, tmpTotal); } catch (Exception ignored) {}
149-
try { operations.rename(dayKey, tmpDay); } catch (Exception ignored) {}
150-
return null;
135+
long startTime = System.currentTimeMillis();
136+
int totalUpdates = 0;
137+
int dailyUpdates = 0;
138+
139+
// 读取总点击快照并刷库
140+
java.util.Map<Object, Object> totalSnapshot = redisTemplate.opsForHash()
141+
.entries("tf:click:snapshot:total");
142+
for (var entry : totalSnapshot.entrySet()) {
143+
String code = String.valueOf(entry.getKey());
144+
long count = toLong(entry.getValue());
145+
if (count > 0) {
146+
shortUrlRepository.incrementClickCountBy(code, count);
147+
totalUpdates++;
151148
}
152-
});
153-
} catch (Exception ignored) {}
154-
155-
java.util.Map<Object,Object> total = java.util.Collections.emptyMap();
156-
java.util.Map<Object,Object> day = java.util.Collections.emptyMap();
157-
try { total = redisTemplate.<Object,Object>opsForHash().entries(tmpTotal); } catch (Exception ignored) {}
158-
try { day = redisTemplate.<Object,Object>opsForHash().entries(tmpDay); } catch (Exception ignored) {}
159-
160-
for (var e : total.entrySet()) {
161-
String code = String.valueOf(e.getKey());
162-
long delta = toLong(e.getValue());
163-
if (delta > 0) { shortUrlRepository.incrementClickCountBy(code, delta); }
164-
}
165-
for (var e : day.entrySet()) {
166-
String code = String.valueOf(e.getKey());
167-
long delta = toLong(e.getValue());
168-
if (delta > 0) { dailyClickRepo.incrementClickBy(code, delta); }
169-
}
170-
171-
// 使用Pipeline批量删除临时key
172-
try {
173-
redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
174-
@Override
175-
public Object execute(org.springframework.data.redis.core.RedisOperations operations) {
176-
try { operations.delete(tmpTotal); } catch (Exception ignored) {}
177-
try { operations.delete(tmpDay); } catch (Exception ignored) {}
178-
return null;
149+
}
150+
151+
// 读取每日点击快照并刷库
152+
String daySnapshotKey = "tf:click:snapshot:day:" + LocalDate.now();
153+
java.util.Map<Object, Object> daySnapshot = redisTemplate.opsForHash()
154+
.entries(daySnapshotKey);
155+
for (var entry : daySnapshot.entrySet()) {
156+
String code = String.valueOf(entry.getKey());
157+
long count = toLong(entry.getValue());
158+
if (count > 0) {
159+
dailyClickRepo.incrementClickBy(code, count);
160+
dailyUpdates++;
179161
}
180-
});
181-
} catch (Exception ignored) {}
162+
}
163+
164+
// 刷库成功后,清空 Redis 快照(只清快照,本地内存继续累加)
165+
redisTemplate.delete("tf:click:snapshot:total");
166+
redisTemplate.delete(daySnapshotKey);
167+
168+
long duration = System.currentTimeMillis() - startTime;
169+
log.info("[SYNC] Persisted {} total clicks and {} daily clicks to DB, duration={}ms",
170+
totalUpdates, dailyUpdates, duration);
171+
} catch (Exception e) {
172+
log.error("[SYNC ERROR] Failed to sync from Redis to DB: {}", e.getMessage(), e);
173+
}
182174
}
183175

184176
private long toLong(Object value) {

src/main/resources/application.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,11 @@ management:
130130

131131
events:
132132
sampleRate: 0.0
133-
# 点击计数模式:mq(消息队列,推荐) | local(本地内存) | redis(Pipeline)
134-
# 暂时使用 redis 模式,等 RabbitMQ 安装后改为 mq
133+
134+
# 点击计数模式:使用本地内存 + Redis 快照方案
135+
# 方案 C:第一阶段(10s)本地→Redis快照,第二阶段(60s)Redis→数据库
135136
clicks:
136-
mode: redis
137+
mode: local # 仍配置为 local(第一阶段),第二阶段由定时任务驱动
137138

138139
cache:
139140
caffeine:

web/App.vue

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
</button>
104104
<div class="mt-4 flex gap-3">
105105
<button @click="copyShortUrl" class="fs-btn-secondary px-4 py-2">
106-
{{ $t('result.copy') }}
106+
{{ copyLabel }}
107107
</button>
108108
<button @click="downloadQrPng" class="fs-btn-secondary px-4 py-2">
109109
{{ $t('result.downloadQr') }}
@@ -614,6 +614,11 @@ export default {
614614
this.refreshHistory()
615615
},
616616
startEdit(item) {
617+
// 检查是否已登录
618+
if (!this.isAuthenticated) {
619+
alert('请先登录后再编辑短链接')
620+
return
621+
}
617622
this.editingId = item?.id || null
618623
const code = this.extractCode(item)
619624
this.editAlias = code || ''
@@ -628,6 +633,13 @@ export default {
628633
const newAlias = (this.editAlias || '').trim()
629634
if (!id || !oldCode) return
630635
if (!newAlias) { alert('请输入新的短码或别名'); return }
636+
637+
// 检查是否已登录
638+
if (!this.isAuthenticated) {
639+
alert('请先登录后再编辑短链接')
640+
return
641+
}
642+
631643
this.updatingIds.add(id)
632644
try {
633645
// 调用后端更新接口:PUT /api/{shortCode}
@@ -642,7 +654,15 @@ export default {
642654
this.history = (this.history || []).map(x => x.id === id ? updated : x)
643655
this.cancelEdit()
644656
} catch (err) {
645-
alert('更新失败,请稍后重试')
657+
const status = err?.response?.status
658+
if (status === 401) {
659+
alert('会话已过期,请重新登录')
660+
window.location.href = '/login'
661+
} else if (status === 403) {
662+
alert('您没有权限修改此短链接')
663+
} else {
664+
alert('更新失败,请稍后重试')
665+
}
646666
console.error('Update short url error:', err)
647667
} finally {
648668
this.updatingIds.delete(id)
@@ -652,6 +672,13 @@ export default {
652672
const id = item?.id
653673
const code = this.extractCode(item)
654674
if (!code) return
675+
676+
// 检查是否已登录
677+
if (!this.isAuthenticated) {
678+
alert('请先登录后再删除短链接')
679+
return
680+
}
681+
655682
if (id) this.deletingIds.add(id)
656683
try {
657684
// 改为按短码删除:DELETE /api/{shortCode}
@@ -661,7 +688,15 @@ export default {
661688
this.history = (this.history || []).filter(x => this.extractCode(x) !== code)
662689
}
663690
} catch (err) {
664-
alert('删除失败,请稍后重试')
691+
const status = err?.response?.status
692+
if (status === 401) {
693+
alert('会话已过期,请重新登录')
694+
window.location.href = '/login'
695+
} else if (status === 403) {
696+
alert('您没有权限删除此短链接')
697+
} else {
698+
alert('删除失败,请稍后重试')
699+
}
665700
console.error('History delete error:', err)
666701
} finally {
667702
if (id) this.deletingIds.delete(id)

0 commit comments

Comments
 (0)