From dbd322807dbd70f156f095087bd4d17ad448e484 Mon Sep 17 00:00:00 2001 From: wangshun <932054296@qq.com> Date: Fri, 5 Sep 2025 16:24:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=BF=BD=E5=8A=A0=EF=BC=9A?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=9C=A8=E7=BA=BF=E4=BA=BA=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/openisle/config/CachingConfig.java | 2 + .../com/openisle/config/SecurityConfig.java | 5 +- .../openisle/controller/OnlineController.java | 33 ++++++++++ frontend_nuxt/components/HeaderComponent.vue | 62 ++++++++++++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/openisle/controller/OnlineController.java diff --git a/backend/src/main/java/com/openisle/config/CachingConfig.java b/backend/src/main/java/com/openisle/config/CachingConfig.java index ded16138a..268c7f945 100644 --- a/backend/src/main/java/com/openisle/config/CachingConfig.java +++ b/backend/src/main/java/com/openisle/config/CachingConfig.java @@ -36,6 +36,8 @@ public class CachingConfig { public static final String TAG_CACHE_NAME="openisle_tags"; // 分类缓存名 public static final String CATEGORY_CACHE_NAME="openisle_categories"; + // 在线人数缓存名 + public static final String ONLINE_CACHE_NAME="openisle_online"; /** * 自定义Redis的序列化器 diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 9cfc4d880..a4dc948db 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -129,6 +129,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() + .requestMatchers(HttpMethod.GET, "/api/online/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/online/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") @@ -183,7 +185,8 @@ public class SecurityConfig { } } else if (!uri.startsWith("/api/auth") && !publicGet && !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs") - && !uri.startsWith("/api/v3/api-docs")) { + && !uri.startsWith("/api/v3/api-docs") + && !uri.startsWith("/api/online")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Missing token\"}"); diff --git a/backend/src/main/java/com/openisle/controller/OnlineController.java b/backend/src/main/java/com/openisle/controller/OnlineController.java new file mode 100644 index 000000000..2f6a307c2 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/OnlineController.java @@ -0,0 +1,33 @@ +package com.openisle.controller; + +import com.openisle.config.CachingConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; + +/** + * @author smallclover + * @since 2025-09-05 + * 统计在线人数 + */ +@RestController +@RequestMapping("/api/online") +@RequiredArgsConstructor +public class OnlineController { + + private final RedisTemplate redisTemplate; + private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME +":"; + + @PostMapping("/heartbeat") + public void ping(@RequestParam String userId){ + redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150)); + } + + @GetMapping("/count") + public long count(){ + return redisTemplate.keys(ONLINE_KEY+"*").size(); + } +} diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 6393075f8..18d3194f2 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -37,7 +37,12 @@ 邀请 - + +
+ + {{ onlineCount }} +
+
@@ -115,6 +120,46 @@ const userMenu = ref(null) const menuBtn = ref(null) const isCopying = ref(false) +const onlineCount = ref(0) + +// 心跳检测 +async function sendPing() { + try { + // 已登录就用 userId,否则随机生成游客ID + let userId = authState.userId + if (userId) { + // 用户已登录,清理游客 ID + localStorage.removeItem('guestId') + } else { + // 游客模式 + let savedId = localStorage.getItem('guestId') + if (!savedId) { + savedId = `guest-${crypto.randomUUID()}` + localStorage.setItem('guestId', savedId) + } + userId = savedId + } + const res = await fetch(`${API_BASE_URL}/api/online/heartbeat?userId=${userId}`, { + method: 'POST', + }) + } catch (e) { + console.error("心跳失败", e) + } +} + +// 获取在线人数 +async function fetchCount() { + try { + const res = await fetch(`${API_BASE_URL}/api/online/count`, { + method: 'GET', + }) + onlineCount.value = await res.json() + } catch (e) { + console.error("获取在线人数失败", e) + } +} + + const search = () => { showSearch.value = true nextTick(() => { @@ -262,6 +307,12 @@ onMounted(async () => { await updateUnread() }, ) + + // 新增的在线人数逻辑 + sendPing() + fetchCount() + setInterval(sendPing, 120000) // 每 2 分钟发一次心跳 + setInterval(fetchCount, 60000) // 每 1 分更新 UI }) @@ -452,6 +503,15 @@ onMounted(async () => { animation: rss-glow 2s 3; } +.online-count { + font-size: 14px; + display: flex; + align-items: center; + gap: 5px; + color: var(--primary-color); + cursor: default; +} + @keyframes rss-glow { 0% { text-shadow: 0 0 0px var(--primary-color);