From dccf8f9d0cb4ba29abd1952fdb104dc965d40eaf Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 30 Jul 2025 10:48:02 +0800
Subject: [PATCH 1/6] feat: add browser push notifications
---
README.md | 2 +
open-isle-cli/public/notifications-sw.js | 9 ++++
open-isle-cli/src/main.js | 2 +
open-isle-cli/src/utils/push.js | 48 +++++++++++++++++++
pom.xml | 10 ++++
.../PushSubscriptionController.java | 41 ++++++++++++++++
.../com/openisle/model/PushSubscription.java | 40 ++++++++++++++++
.../PushSubscriptionRepository.java | 12 +++++
.../openisle/service/NotificationService.java | 5 +-
.../service/PushNotificationService.java | 42 ++++++++++++++++
.../service/PushSubscriptionService.java | 33 +++++++++++++
src/main/resources/application.properties | 4 ++
.../PushSubscriptionControllerTest.java | 36 ++++++++++++++
13 files changed, 283 insertions(+), 1 deletion(-)
create mode 100644 open-isle-cli/public/notifications-sw.js
create mode 100644 open-isle-cli/src/utils/push.js
create mode 100644 src/main/java/com/openisle/controller/PushSubscriptionController.java
create mode 100644 src/main/java/com/openisle/model/PushSubscription.java
create mode 100644 src/main/java/com/openisle/repository/PushSubscriptionRepository.java
create mode 100644 src/main/java/com/openisle/service/PushNotificationService.java
create mode 100644 src/main/java/com/openisle/service/PushSubscriptionService.java
create mode 100644 src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java
diff --git a/README.md b/README.md
index 31b460d24..f21bc7bd9 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 集成 OpenAI 提供的 Markdown 格式化功能
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
- 支持图片上传,默认使用腾讯云 COS 扩展
+- 浏览器推送通知,离开网站也能及时收到提醒
## 🌟 项目优势
- 全面开源,便于二次开发和自定义扩展
@@ -39,6 +40,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 模块化设计,代码结构清晰,维护成本低
- REST API 可接入任意前端框架,兼容多端平台
- 配置简单,通过环境变量快速调整和部署
+- 如需推送通知,请设置 `WEBPUSH_PUBLIC_KEY` 和 `WEBPUSH_PRIVATE_KEY` 环境变量
## 🏘️ 社区
diff --git a/open-isle-cli/public/notifications-sw.js b/open-isle-cli/public/notifications-sw.js
new file mode 100644
index 000000000..bc6378131
--- /dev/null
+++ b/open-isle-cli/public/notifications-sw.js
@@ -0,0 +1,9 @@
+self.addEventListener('push', function(event) {
+ const data = event.data ? event.data.text() : 'New notification';
+ event.waitUntil(
+ self.registration.showNotification('OpenIsle', {
+ body: data,
+ icon: '/favicon.ico'
+ })
+ );
+});
diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js
index 3642b4dac..c7c82f1fe 100644
--- a/open-isle-cli/src/main.js
+++ b/open-isle-cli/src/main.js
@@ -11,6 +11,7 @@ import { useToast } from 'vue-toastification'
import { checkToken, clearToken, isLogin } from './utils/auth'
import { initTheme } from './utils/theme'
import { loginWithGoogle } from './utils/google'
+import { registerPush } from './utils/push'
// Configurable API domain and port
// export const API_DOMAIN = 'http://127.0.0.1'
@@ -43,6 +44,7 @@ app.use(
)
app.mount('#app')
+registerPush()
checkToken().then(valid => {
if (!valid) {
diff --git a/open-isle-cli/src/utils/push.js b/open-isle-cli/src/utils/push.js
new file mode 100644
index 000000000..201b8cc60
--- /dev/null
+++ b/open-isle-cli/src/utils/push.js
@@ -0,0 +1,48 @@
+import { API_BASE_URL } from '../main'
+import { getToken } from './auth'
+
+function urlBase64ToUint8Array(base64String) {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4)
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
+ const rawData = atob(base64)
+ const outputArray = new Uint8Array(rawData.length)
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i)
+ }
+ return outputArray
+}
+
+function arrayBufferToBase64(buffer) {
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+ for (const b of bytes) binary += String.fromCharCode(b)
+ return btoa(binary)
+}
+
+export async function registerPush() {
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
+ try {
+ const reg = await navigator.serviceWorker.register('/notifications-sw.js')
+ const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
+ if (!res.ok) return
+ const { key } = await res.json()
+ const sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(key)
+ })
+ await fetch(`${API_BASE_URL}/api/push/subscribe`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${getToken()}`
+ },
+ body: JSON.stringify({
+ endpoint: sub.endpoint,
+ p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
+ auth: arrayBufferToBase64(sub.getKey('auth'))
+ })
+ })
+ } catch (e) {
+ // ignore
+ }
+}
diff --git a/pom.xml b/pom.xml
index cc82b1772..445fa4058 100644
--- a/pom.xml
+++ b/pom.xml
@@ -90,6 +90,16 @@
spring-boot-starter-test
test
+
+ nl.martijndwars
+ web-push
+ 5.1.1
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+ 1.70
+
diff --git a/src/main/java/com/openisle/controller/PushSubscriptionController.java b/src/main/java/com/openisle/controller/PushSubscriptionController.java
new file mode 100644
index 000000000..800287b40
--- /dev/null
+++ b/src/main/java/com/openisle/controller/PushSubscriptionController.java
@@ -0,0 +1,41 @@
+package com.openisle.controller;
+
+import com.openisle.service.PushSubscriptionService;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/push")
+@RequiredArgsConstructor
+public class PushSubscriptionController {
+ private final PushSubscriptionService pushSubscriptionService;
+ @Value("${app.webpush.public-key}")
+ private String publicKey;
+
+ @GetMapping("/public-key")
+ public PublicKeyResponse getPublicKey() {
+ PublicKeyResponse r = new PublicKeyResponse();
+ r.setKey(publicKey);
+ return r;
+ }
+
+ @PostMapping("/subscribe")
+ public void subscribe(@RequestBody SubscriptionRequest req, Authentication auth) {
+ pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
+ }
+
+ @Data
+ private static class PublicKeyResponse {
+ private String key;
+ }
+
+ @Data
+ private static class SubscriptionRequest {
+ private String endpoint;
+ private String p256dh;
+ private String auth;
+ }
+}
diff --git a/src/main/java/com/openisle/model/PushSubscription.java b/src/main/java/com/openisle/model/PushSubscription.java
new file mode 100644
index 000000000..34888513e
--- /dev/null
+++ b/src/main/java/com/openisle/model/PushSubscription.java
@@ -0,0 +1,40 @@
+package com.openisle.model;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.hibernate.annotations.CreationTimestamp;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entity storing a browser push subscription for a user.
+ */
+@Entity
+@Getter
+@Setter
+@NoArgsConstructor
+@Table(name = "push_subscriptions")
+public class PushSubscription {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Column(nullable = false, length = 512)
+ private String endpoint;
+
+ @Column(nullable = false, length = 256)
+ private String p256dh;
+
+ @Column(nullable = false, length = 256)
+ private String auth;
+
+ @CreationTimestamp
+ @Column(nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/openisle/repository/PushSubscriptionRepository.java b/src/main/java/com/openisle/repository/PushSubscriptionRepository.java
new file mode 100644
index 000000000..28268d203
--- /dev/null
+++ b/src/main/java/com/openisle/repository/PushSubscriptionRepository.java
@@ -0,0 +1,12 @@
+package com.openisle.repository;
+
+import com.openisle.model.PushSubscription;
+import com.openisle.model.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface PushSubscriptionRepository extends JpaRepository {
+ List findByUser(User user);
+ void deleteByUserAndEndpoint(User user, String endpoint);
+}
diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java
index 73ef61929..e4daa73f2 100644
--- a/src/main/java/com/openisle/service/NotificationService.java
+++ b/src/main/java/com/openisle/service/NotificationService.java
@@ -14,6 +14,7 @@ import java.util.List;
public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;
+ private final PushNotificationService pushNotificationService;
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) {
return createNotification(user, type, post, comment, approved, null, null, null);
@@ -30,7 +31,9 @@ public class NotificationService {
n.setFromUser(fromUser);
n.setReactionType(reactionType);
n.setContent(content);
- return notificationRepository.save(n);
+ Notification saved = notificationRepository.save(n);
+ pushNotificationService.sendNotification(user, "You have a new notification");
+ return saved;
}
/**
diff --git a/src/main/java/com/openisle/service/PushNotificationService.java b/src/main/java/com/openisle/service/PushNotificationService.java
new file mode 100644
index 000000000..862fb9904
--- /dev/null
+++ b/src/main/java/com/openisle/service/PushNotificationService.java
@@ -0,0 +1,42 @@
+package com.openisle.service;
+
+import com.openisle.model.PushSubscription;
+import com.openisle.model.User;
+import com.openisle.repository.PushSubscriptionRepository;
+import nl.martijndwars.webpush.Notification;
+import nl.martijndwars.webpush.PushService;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.jose4j.lang.JoseException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.Security;
+import java.util.List;
+
+@Service
+public class PushNotificationService {
+ private final PushSubscriptionRepository subscriptionRepository;
+ private final PushService pushService;
+
+ public PushNotificationService(PushSubscriptionRepository subscriptionRepository,
+ @Value("${app.webpush.public-key}") String publicKey,
+ @Value("${app.webpush.private-key}") String privateKey) throws GeneralSecurityException {
+ this.subscriptionRepository = subscriptionRepository;
+ Security.addProvider(new BouncyCastleProvider());
+ this.pushService = new PushService(publicKey, privateKey);
+ }
+
+ public void sendNotification(User user, String payload) {
+ List subs = subscriptionRepository.findByUser(user);
+ for (PushSubscription sub : subs) {
+ try {
+ Notification notification = new Notification(sub.getEndpoint(), sub.getP256dh(), sub.getAuth(), payload);
+ pushService.send(notification);
+ } catch (GeneralSecurityException | IOException | JoseException | InterruptedException | java.util.concurrent.ExecutionException e) {
+ // ignore
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/openisle/service/PushSubscriptionService.java b/src/main/java/com/openisle/service/PushSubscriptionService.java
new file mode 100644
index 000000000..6d76c2b85
--- /dev/null
+++ b/src/main/java/com/openisle/service/PushSubscriptionService.java
@@ -0,0 +1,33 @@
+package com.openisle.service;
+
+import com.openisle.model.PushSubscription;
+import com.openisle.model.User;
+import com.openisle.repository.PushSubscriptionRepository;
+import com.openisle.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class PushSubscriptionService {
+ private final PushSubscriptionRepository subscriptionRepository;
+ private final UserRepository userRepository;
+
+ public void saveSubscription(String username, String endpoint, String p256dh, String auth) {
+ User user = userRepository.findByUsername(username)
+ .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
+ subscriptionRepository.deleteByUserAndEndpoint(user, endpoint);
+ PushSubscription sub = new PushSubscription();
+ sub.setUser(user);
+ sub.setEndpoint(endpoint);
+ sub.setP256dh(p256dh);
+ sub.setAuth(auth);
+ subscriptionRepository.save(sub);
+ }
+
+ public List listByUser(User user) {
+ return subscriptionRepository.findByUser(user);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 7d74a929c..f1e2b2b30 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -70,3 +70,7 @@ app.ai.format-limit=${AI_FORMAT_LIMIT:3}
# Website URL for emails and redirects
app.website-url=${WEBSITE_URL:https://www.open-isle.com}
+
+# Web push configuration
+app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
+app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
diff --git a/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java b/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java
new file mode 100644
index 000000000..fbb31b718
--- /dev/null
+++ b/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java
@@ -0,0 +1,36 @@
+package com.openisle.controller;
+
+import com.openisle.service.PushSubscriptionService;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(PushSubscriptionController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class PushSubscriptionControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private PushSubscriptionService pushSubscriptionService;
+
+ @Test
+ void subscribeEndpoint() throws Exception {
+ mockMvc.perform(post("/api/push/subscribe")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"endpoint\":\"e\",\"p256dh\":\"p\",\"auth\":\"a\"}")
+ .principal(new UsernamePasswordAuthenticationToken("u","p")))
+ .andExpect(status().isOk());
+ Mockito.verify(pushSubscriptionService).saveSubscription("u","e","p","a");
+ }
+}
From aa138afe61717a81913384d2e9bddd7bd070c3f9 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 30 Jul 2025 11:35:28 +0800
Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=8E=A8=E9=80=81=E9=93=BE?=
=?UTF-8?q?=E8=B7=AF=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
open-isle-cli/src/utils/discord.js | 2 ++
open-isle-cli/src/utils/github.js | 2 ++
open-isle-cli/src/utils/google.js | 2 ++
open-isle-cli/src/utils/twitter.js | 2 ++
open-isle-cli/src/views/LoginPageView.vue | 2 ++
src/main/java/com/openisle/config/SecurityConfig.java | 3 ++-
.../java/com/openisle/service/PushNotificationService.java | 4 +++-
.../java/com/openisle/service/PushSubscriptionService.java | 2 ++
8 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/open-isle-cli/src/utils/discord.js b/open-isle-cli/src/utils/discord.js
index 4fa45ac84..521ecc8fa 100644
--- a/open-isle-cli/src/utils/discord.js
+++ b/open-isle-cli/src/utils/discord.js
@@ -1,6 +1,7 @@
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { setToken, loadCurrentUser } from './auth'
+import { registerPush } from './push'
export function discordAuthorize(state = '') {
if (!DISCORD_CLIENT_ID) {
@@ -24,6 +25,7 @@ export async function discordExchange(code, state, reason) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
+ registerPush()
return {
success: true,
needReason: false
diff --git a/open-isle-cli/src/utils/github.js b/open-isle-cli/src/utils/github.js
index 4b0cb6f68..cca908654 100644
--- a/open-isle-cli/src/utils/github.js
+++ b/open-isle-cli/src/utils/github.js
@@ -1,6 +1,7 @@
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { WEBSITE_BASE_URL } from '../constants'
+import { registerPush } from './push'
export function githubAuthorize(state = '') {
if (!GITHUB_CLIENT_ID) {
@@ -24,6 +25,7 @@ export async function githubExchange(code, state, reason) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
+ registerPush()
return {
success: true,
needReason: false
diff --git a/open-isle-cli/src/utils/google.js b/open-isle-cli/src/utils/google.js
index fcdf0c92b..533d6c808 100644
--- a/open-isle-cli/src/utils/google.js
+++ b/open-isle-cli/src/utils/google.js
@@ -1,5 +1,6 @@
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
+import { registerPush } from './push'
export async function googleGetIdToken() {
return new Promise((resolve, reject) => {
@@ -29,6 +30,7 @@ export async function googleAuthWithToken(idToken, redirect_success, redirect_no
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
+ registerPush()
if (redirect_success) redirect_success()
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
diff --git a/open-isle-cli/src/utils/twitter.js b/open-isle-cli/src/utils/twitter.js
index cc19da4db..8362d17af 100644
--- a/open-isle-cli/src/utils/twitter.js
+++ b/open-isle-cli/src/utils/twitter.js
@@ -1,6 +1,7 @@
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { setToken, loadCurrentUser } from './auth'
+import { registerPush } from './push'
function generateCodeVerifier() {
const array = new Uint8Array(32)
@@ -59,6 +60,7 @@ export async function twitterExchange(code, state, reason) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
+ registerPush()
return { success: true, needReason: false }
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
diff --git a/open-isle-cli/src/views/LoginPageView.vue b/open-isle-cli/src/views/LoginPageView.vue
index c8418ca9d..cba93fb48 100644
--- a/open-isle-cli/src/views/LoginPageView.vue
+++ b/open-isle-cli/src/views/LoginPageView.vue
@@ -59,6 +59,7 @@ import { githubAuthorize } from '../utils/github'
import { discordAuthorize } from '../utils/discord'
import { twitterAuthorize } from '../utils/twitter'
import BaseInput from '../components/BaseInput.vue'
+import { registerPush } from '../utils/push'
export default {
name: 'LoginPageView',
components: { BaseInput },
@@ -87,6 +88,7 @@ export default {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
+ registerPush()
this.$router.push('/')
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java
index 44ef64d65..e93419ca2 100644
--- a/src/main/java/com/openisle/config/SecurityConfig.java
+++ b/src/main/java/com/openisle/config/SecurityConfig.java
@@ -108,6 +108,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
@@ -140,7 +141,7 @@ public class SecurityConfig {
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
- uri.startsWith("/api/activities"));
+ uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
diff --git a/src/main/java/com/openisle/service/PushNotificationService.java b/src/main/java/com/openisle/service/PushNotificationService.java
index 862fb9904..8f9336e2d 100644
--- a/src/main/java/com/openisle/service/PushNotificationService.java
+++ b/src/main/java/com/openisle/service/PushNotificationService.java
@@ -3,6 +3,7 @@ package com.openisle.service;
import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import com.openisle.repository.PushSubscriptionRepository;
+import lombok.extern.slf4j.Slf4j;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
@@ -15,6 +16,7 @@ import java.security.GeneralSecurityException;
import java.security.Security;
import java.util.List;
+@Slf4j
@Service
public class PushNotificationService {
private final PushSubscriptionRepository subscriptionRepository;
@@ -35,7 +37,7 @@ public class PushNotificationService {
Notification notification = new Notification(sub.getEndpoint(), sub.getP256dh(), sub.getAuth(), payload);
pushService.send(notification);
} catch (GeneralSecurityException | IOException | JoseException | InterruptedException | java.util.concurrent.ExecutionException e) {
- // ignore
+ log.error(e.getMessage());
}
}
}
diff --git a/src/main/java/com/openisle/service/PushSubscriptionService.java b/src/main/java/com/openisle/service/PushSubscriptionService.java
index 6d76c2b85..5a062b302 100644
--- a/src/main/java/com/openisle/service/PushSubscriptionService.java
+++ b/src/main/java/com/openisle/service/PushSubscriptionService.java
@@ -6,6 +6,7 @@ import com.openisle.repository.PushSubscriptionRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -15,6 +16,7 @@ public class PushSubscriptionService {
private final PushSubscriptionRepository subscriptionRepository;
private final UserRepository userRepository;
+ @Transactional
public void saveSubscription(String username, String endpoint, String p256dh, String auth) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
From 40c919348f1d6772a0b00c087f3cc7ee88c6f5c9 Mon Sep 17 00:00:00 2001
From: Tim <135014430+nagisa77@users.noreply.github.com>
Date: Wed, 30 Jul 2025 11:53:43 +0800
Subject: [PATCH 3/6] feat: improve push notifications
---
open-isle-cli/public/notifications-sw.js | 24 +++++++++++++----
.../openisle/service/NotificationService.java | 27 +++++++++++++++++--
.../com/openisle/service/ReactionService.java | 14 +++++++---
.../service/NotificationServiceTest.java | 20 +++++++++-----
.../openisle/service/ReactionServiceTest.java | 1 +
5 files changed, 69 insertions(+), 17 deletions(-)
diff --git a/open-isle-cli/public/notifications-sw.js b/open-isle-cli/public/notifications-sw.js
index bc6378131..1d8aacc13 100644
--- a/open-isle-cli/public/notifications-sw.js
+++ b/open-isle-cli/public/notifications-sw.js
@@ -1,9 +1,23 @@
self.addEventListener('push', function(event) {
- const data = event.data ? event.data.text() : 'New notification';
+ let payload = { body: 'New notification', url: '/' }
+ try {
+ if (event.data) payload = JSON.parse(event.data.text())
+ } catch (e) {
+ if (event.data) payload.body = event.data.text()
+ }
event.waitUntil(
self.registration.showNotification('OpenIsle', {
- body: data,
- icon: '/favicon.ico'
+ body: payload.body,
+ icon: '/favicon.ico',
+ data: { url: payload.url }
})
- );
-});
+ )
+})
+
+self.addEventListener('notificationclick', function(event) {
+ const url = event.notification.data && event.notification.data.url
+ event.notification.close()
+ if (url) {
+ event.waitUntil(clients.openWindow(url))
+ }
+})
diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java
index a4b805fc9..371879430 100644
--- a/src/main/java/com/openisle/service/NotificationService.java
+++ b/src/main/java/com/openisle/service/NotificationService.java
@@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor;
import com.openisle.service.EmailSender;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Map;
import java.util.List;
@@ -22,6 +24,21 @@ public class NotificationService {
@Value("${app.website-url}")
private String websiteUrl;
+ private String buildPayload(String body, String url) {
+ try {
+ return new ObjectMapper().writeValueAsString(Map.of(
+ "body", body,
+ "url", url
+ ));
+ } catch (Exception e) {
+ return body;
+ }
+ }
+
+ public void sendCustomPush(User user, String body, String url) {
+ pushNotificationService.sendNotification(user, buildPayload(body, url));
+ }
+
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) {
return createNotification(user, type, post, comment, approved, null, null, null);
}
@@ -38,10 +55,16 @@ public class NotificationService {
n.setReactionType(reactionType);
n.setContent(content);
n = notificationRepository.save(n);
- pushNotificationService.sendNotification(user, "You have a new notification");
+
+ String body = "You have a new notification";
+ String url = websiteUrl + "/messages";
+ if (type == NotificationType.COMMENT_REPLY && post != null && comment != null) {
+ body = "有人回复了你";
+ url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
+ }
+ sendCustomPush(user, body, url);
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
- String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
emailSender.sendEmail(user.getEmail(), "【OpenIsle】有人回复了你", url);
}
diff --git a/src/main/java/com/openisle/service/ReactionService.java b/src/main/java/com/openisle/service/ReactionService.java
index b752b828a..deb7cc1b6 100644
--- a/src/main/java/com/openisle/service/ReactionService.java
+++ b/src/main/java/com/openisle/service/ReactionService.java
@@ -48,9 +48,12 @@ public class ReactionService {
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type, null);
long count = reactionRepository.countReceived(post.getAuthor().getUsername());
- if (count % 5 == 0 && post.getAuthor().getEmail() != null) {
+ if (count % 5 == 0) {
String url = websiteUrl + "/messages";
- emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ notificationService.sendCustomPush(post.getAuthor(), "你有新的互动", url);
+ if (post.getAuthor().getEmail() != null) {
+ emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ }
}
}
return reaction;
@@ -76,9 +79,12 @@ public class ReactionService {
if (!user.getId().equals(comment.getAuthor().getId())) {
notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type, null);
long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
- if (count % 5 == 0 && comment.getAuthor().getEmail() != null) {
+ if (count % 5 == 0) {
String url = websiteUrl + "/messages";
- emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ notificationService.sendCustomPush(comment.getAuthor(), "你有新的互动", url);
+ if (comment.getAuthor().getEmail() != null) {
+ emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ }
}
}
return reaction;
diff --git a/src/test/java/com/openisle/service/NotificationServiceTest.java b/src/test/java/com/openisle/service/NotificationServiceTest.java
index 82bcd12e3..1dca76b33 100644
--- a/src/test/java/com/openisle/service/NotificationServiceTest.java
+++ b/src/test/java/com/openisle/service/NotificationServiceTest.java
@@ -3,6 +3,7 @@ package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository;
+import com.openisle.service.PushNotificationService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -19,7 +20,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
@@ -47,7 +49,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
@@ -69,7 +72,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
@@ -89,7 +93,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User admin = new User();
@@ -110,7 +115,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User admin = new User();
@@ -131,7 +137,8 @@ class NotificationServiceTest {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
- NotificationService service = new NotificationService(nRepo, uRepo, email);
+ PushNotificationService push = mock(PushNotificationService.class);
+ NotificationService service = new NotificationService(nRepo, uRepo, email, push);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
@@ -145,5 +152,6 @@ class NotificationServiceTest {
service.createNotification(user, NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
verify(email).sendEmail("a@a.com", "【OpenIsle】有人回复了你", "https://ex.com/posts/1#comment-2");
+ verify(push).sendNotification(eq(user), contains("/posts/1#comment-2"));
}
}
diff --git a/src/test/java/com/openisle/service/ReactionServiceTest.java b/src/test/java/com/openisle/service/ReactionServiceTest.java
index fea2e9fd9..9f797fee0 100644
--- a/src/test/java/com/openisle/service/ReactionServiceTest.java
+++ b/src/test/java/com/openisle/service/ReactionServiceTest.java
@@ -39,5 +39,6 @@ class ReactionServiceTest {
service.reactToPost("bob", 3L, ReactionType.LIKE);
verify(email).sendEmail("a@a.com", "【OpenIsle】你有新的互动", "https://ex.com/messages");
+ verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages");
}
}
From 748f7f9709f019c4235f6a6427b2458e370d6f60 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 30 Jul 2025 12:06:23 +0800
Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E9=82=AE=E4=BB=B6=E3=80=81?=
=?UTF-8?q?=E8=AE=AF=E6=81=AF=E9=80=9A=E7=9F=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../openisle/service/NotificationService.java | 30 ++++++++++++++-----
.../com/openisle/service/ReactionService.java | 16 ----------
2 files changed, 22 insertions(+), 24 deletions(-)
diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java
index 371879430..27233f5a7 100644
--- a/src/main/java/com/openisle/service/NotificationService.java
+++ b/src/main/java/com/openisle/service/NotificationService.java
@@ -2,6 +2,7 @@ package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.NotificationRepository;
+import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import com.openisle.service.EmailSender;
@@ -20,6 +21,7 @@ public class NotificationService {
private final UserRepository userRepository;
private final EmailSender emailSender;
private final PushNotificationService pushNotificationService;
+ private final ReactionRepository reactionRepository;
@Value("${app.website-url}")
private String websiteUrl;
@@ -56,16 +58,28 @@ public class NotificationService {
n.setContent(content);
n = notificationRepository.save(n);
- String body = "You have a new notification";
- String url = websiteUrl + "/messages";
- if (type == NotificationType.COMMENT_REPLY && post != null && comment != null) {
- body = "有人回复了你";
- url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
- }
- sendCustomPush(user, body, url);
-
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
+ String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
emailSender.sendEmail(user.getEmail(), "【OpenIsle】有人回复了你", url);
+ sendCustomPush(user, "有人回复了你", url);
+ } else if (type == NotificationType.REACTION && comment != null) {
+ long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
+ if (count % 5 == 0) {
+ String url = websiteUrl + "/messages";
+ sendCustomPush(comment.getAuthor(), "你有新的互动", url);
+ if (comment.getAuthor().getEmail() != null) {
+ emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ }
+ }
+ } else if (type == NotificationType.REACTION && post != null) {
+ long count = reactionRepository.countReceived(post.getAuthor().getUsername());
+ if (count % 5 == 0) {
+ String url = websiteUrl + "/messages";
+ sendCustomPush(post.getAuthor(), "你有新的互动", url);
+ if (post.getAuthor().getEmail() != null) {
+ emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ }
+ }
}
return n;
diff --git a/src/main/java/com/openisle/service/ReactionService.java b/src/main/java/com/openisle/service/ReactionService.java
index deb7cc1b6..393de62b3 100644
--- a/src/main/java/com/openisle/service/ReactionService.java
+++ b/src/main/java/com/openisle/service/ReactionService.java
@@ -47,14 +47,6 @@ public class ReactionService {
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type, null);
- long count = reactionRepository.countReceived(post.getAuthor().getUsername());
- if (count % 5 == 0) {
- String url = websiteUrl + "/messages";
- notificationService.sendCustomPush(post.getAuthor(), "你有新的互动", url);
- if (post.getAuthor().getEmail() != null) {
- emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
- }
- }
}
return reaction;
}
@@ -78,14 +70,6 @@ public class ReactionService {
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(comment.getAuthor().getId())) {
notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type, null);
- long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
- if (count % 5 == 0) {
- String url = websiteUrl + "/messages";
- notificationService.sendCustomPush(comment.getAuthor(), "你有新的互动", url);
- if (comment.getAuthor().getEmail() != null) {
- emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
- }
- }
}
return reaction;
}
From 995cfdf87e6261e972b074b788ffb353bd2cfc54 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 30 Jul 2025 12:14:11 +0800
Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E9=82=AE?=
=?UTF-8?q?=E4=BB=B6=E6=A0=BC=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../openisle/service/NotificationService.java | 27 ++++++++++---------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java
index 27233f5a7..ccfbecbbd 100644
--- a/src/main/java/com/openisle/service/NotificationService.java
+++ b/src/main/java/com/openisle/service/NotificationService.java
@@ -27,14 +27,16 @@ public class NotificationService {
private String websiteUrl;
private String buildPayload(String body, String url) {
- try {
- return new ObjectMapper().writeValueAsString(Map.of(
- "body", body,
- "url", url
- ));
- } catch (Exception e) {
- return body;
- }
+// try {
+// return new ObjectMapper().writeValueAsString(Map.of(
+// "body", body,
+// "url", url
+// ));
+// } catch (Exception e) {
+// return body;
+// }
+
+ return body;
}
public void sendCustomPush(User user, String body, String url) {
@@ -60,15 +62,16 @@ public class NotificationService {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
- emailSender.sendEmail(user.getEmail(), "【OpenIsle】有人回复了你", url);
- sendCustomPush(user, "有人回复了你", url);
+ String pushContent = comment.getAuthor() + "回复了你: \"" + comment.getContent() + "\"";
+ emailSender.sendEmail(user.getEmail(), "【OpenIsle】您有新的回复", pushContent + ", 点击以查看: " + url);
+ sendCustomPush(user, pushContent, url);
} else if (type == NotificationType.REACTION && comment != null) {
long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
if (count % 5 == 0) {
String url = websiteUrl + "/messages";
sendCustomPush(comment.getAuthor(), "你有新的互动", url);
if (comment.getAuthor().getEmail() != null) {
- emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ emailSender.sendEmail(comment.getAuthor().getEmail(), "【OpenIsle】你有新的互动", "你有新的互动, 点击以查看: " + url);
}
}
} else if (type == NotificationType.REACTION && post != null) {
@@ -77,7 +80,7 @@ public class NotificationService {
String url = websiteUrl + "/messages";
sendCustomPush(post.getAuthor(), "你有新的互动", url);
if (post.getAuthor().getEmail() != null) {
- emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", url);
+ emailSender.sendEmail(post.getAuthor().getEmail(), "【OpenIsle】你有新的互动", "你有新的互动, 点击以查看: " + url);
}
}
}
From 854401ca8deb7faa16d761c37d71f824b325af32 Mon Sep 17 00:00:00 2001
From: Tim
Date: Wed, 30 Jul 2025 12:16:20 +0800
Subject: [PATCH 6/6] feat: delete useless code
---
open-isle-cli/src/main.js | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js
index 71b4e2070..3642b4dac 100644
--- a/open-isle-cli/src/main.js
+++ b/open-isle-cli/src/main.js
@@ -13,14 +13,14 @@ import { initTheme } from './utils/theme'
import { loginWithGoogle } from './utils/google'
// Configurable API domain and port
-export const API_DOMAIN = 'http://127.0.0.1'
-export const API_PORT = 8081
+// export const API_DOMAIN = 'http://127.0.0.1'
+// export const API_PORT = 8081
-// export const API_DOMAIN = 'http://47.82.99.208'
-// export const API_PORT = 8080
+export const API_DOMAIN = 'http://47.82.99.208'
+export const API_PORT = 8080
-export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
-// export const API_BASE_URL = "";
+// export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
+export const API_BASE_URL = "";
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779'