Compare commits

...

6 Commits

Author SHA1 Message Date
Tim
8767aa31d6 fix(frontend): scroll to bottom on channel entry 2025-09-01 11:30:16 +08:00
Tim
a428f472f2 Merge pull request #809 from nagisa77/codex/shorten-invitation-link
feat: shorten invite links
2025-09-01 11:26:25 +08:00
Tim
8544803e62 feat: shorten invite links 2025-09-01 11:25:32 +08:00
Tim
54874cea7a Merge pull request #808 from nagisa77/codex/add-email-notification-settings
feat: add email notification settings
2025-09-01 11:24:19 +08:00
Tim
098d82a6a0 feat: add email notification settings 2025-09-01 11:23:31 +08:00
Tim
90eee03198 Merge pull request #807 from nagisa77/codex/fix-backend-compilation-issues
test: fix PostServiceTest for new PostService deps
2025-09-01 10:54:07 +08:00
10 changed files with 164 additions and 9 deletions

View File

@@ -62,4 +62,14 @@ public class NotificationController {
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) { public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled()); notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
} }
@GetMapping("/email-prefs")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
} }

View File

@@ -14,6 +14,13 @@ public class InviteToken {
@Id @Id
private String token; private String token;
/**
* Short token used in invite links. Existing records may have this field null
* and fall back to {@link #token} for backward compatibility.
*/
@Column(unique = true)
private String shortToken;
@ManyToOne @ManyToOne
private User inviter; private User inviter;

View File

@@ -74,6 +74,12 @@ public class User {
NotificationType.USER_ACTIVITY NotificationType.USER_ACTIVITY
); );
@ElementCollection(targetClass = NotificationType.class)
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "notification_type")
@Enumerated(EnumType.STRING)
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
@CreationTimestamp @CreationTimestamp
@Column(nullable = false, updatable = false, @Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -9,4 +9,8 @@ import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> { public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate); Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
Optional<InviteToken> findByShortToken(String shortToken);
boolean existsByShortToken(String shortToken);
} }

View File

@@ -30,33 +30,53 @@ public class InviteService {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today); Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) { if (existing.isPresent()) {
return existing.get().getToken(); InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
} }
String token = jwtService.generateInviteToken(username); String token = jwtService.generateInviteToken(username);
String shortToken;
do {
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} while (inviteTokenRepository.existsByShortToken(shortToken));
InviteToken inviteToken = new InviteToken(); InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token); inviteToken.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter); inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today); inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0); inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken); inviteTokenRepository.save(inviteToken);
return token; return shortToken;
} }
public InviteValidateResult validate(String token) { public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) { if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false); return new InviteValidateResult(null, false);
} }
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
String realToken = token;
if (invite == null) {
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
if (invite == null) {
return new InviteValidateResult(null, false);
}
realToken = invite.getToken();
}
try { try {
jwtService.validateAndGetSubjectForInvite(token); jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) { } catch (Exception e) {
return new InviteValidateResult(null, false); return new InviteValidateResult(null, false);
} }
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3); return new InviteValidateResult(invite, invite.getUsageCount() < 3);
} }
public void consume(String token, String newUserName) { public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow(); InviteToken invite = inviteTokenRepository.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1); invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite); inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName); pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);

View File

@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.Set; import java.util.Set;
import java.util.HashSet; import java.util.HashSet;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
@@ -40,6 +41,12 @@ public class NotificationService {
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]"); private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
NotificationType.COMMENT_REPLY,
NotificationType.LOTTERY_WIN,
NotificationType.LOTTERY_DRAW
);
private String buildPayload(String body, String url) { private String buildPayload(String body, String url) {
// Ensure push notifications contain a link to the related resource so // Ensure push notifications contain a link to the related resource so
// that verifications can assert its presence and users can navigate // that verifications can assert its presence and users can navigate
@@ -75,7 +82,8 @@ public class NotificationService {
n = notificationRepository.save(n); n = notificationRepository.save(n);
// Runnable asyncTask = () -> { // Runnable asyncTask = () -> {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) { if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId()); String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
emailSender.sendEmail(user.getEmail(), "有人回复了你", url); emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
sendCustomPush(user, "有人回复了你", url); sendCustomPush(user, "有人回复了你", url);
@@ -187,6 +195,35 @@ public class NotificationService {
userRepository.save(user); userRepository.save(user);
} }
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
List<NotificationPreferenceDto> prefs = new ArrayList<>();
for (NotificationType nt : EMAIL_TYPES) {
NotificationPreferenceDto dto = new NotificationPreferenceDto();
dto.setType(nt);
dto.setEnabled(!disabled.contains(nt));
prefs.add(dto);
}
return prefs;
}
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
if (!EMAIL_TYPES.contains(type)) {
return;
}
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
userRepository.save(user);
}
public List<Notification> listNotifications(String username, Boolean read, int page, int size) { public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -374,14 +374,16 @@ public class PostService {
lp.setWinners(winners); lp.setWinners(winners);
lotteryPostRepository.save(lp); lotteryPostRepository.save(lp);
for (User w : winners) { for (User w : winners) {
if (w.getEmail() != null) { if (w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
} }
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null); notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId())); notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
} }
if (lp.getAuthor() != null) { if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) { if (lp.getAuthor().getEmail() != null &&
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"); emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
} }
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null); notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);

View File

@@ -334,6 +334,9 @@ onMounted(async () => {
if (currentUser.value) { if (currentUser.value) {
await fetchMessages(0) await fetchMessages(0)
await markConversationAsRead() await markConversationAsRead()
await nextTick()
// 初次进入频道时,平滑滚动到底部
scrollToBottomSmooth()
const token = getToken() const token = getToken()
if (token && !isConnected.value) { if (token && !isConnected.value) {
connect(token) connect(token)

View File

@@ -23,6 +23,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="message-control-container">
<div class="message-control-title">邮件通知设置</div>
<div class="message-control-item-container">
<div v-for="pref in emailPrefs" :key="pref.type" class="message-control-item">
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
<BaseSwitch
:model-value="pref.enabled"
@update:modelValue="(val) => toggleEmailPref(pref, val)"
/>
</div>
</div>
</div>
</div> </div>
<template v-else> <template v-else>
@@ -579,6 +591,8 @@ import {
hasMore, hasMore,
fetchNotificationPreferences, fetchNotificationPreferences,
updateNotificationPreference, updateNotificationPreference,
fetchEmailNotificationPreferences,
updateEmailNotificationPreference,
} from '~/utils/notification' } from '~/utils/notification'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import BaseSwitch from '~/components/BaseSwitch.vue' import BaseSwitch from '~/components/BaseSwitch.vue'
@@ -595,6 +609,7 @@ const tabs = [
{ key: 'control', label: '消息设置' }, { key: 'control', label: '消息设置' },
] ]
const notificationPrefs = ref([]) const notificationPrefs = ref([])
const emailPrefs = ref([])
const page = ref(0) const page = ref(0)
const pageSize = 30 const pageSize = 30
@@ -619,6 +634,10 @@ const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences() notificationPrefs.value = await fetchNotificationPreferences()
} }
const fetchEmailPrefs = async () => {
emailPrefs.value = await fetchEmailNotificationPreferences()
}
const togglePref = async (pref, value) => { const togglePref = async (pref, value) => {
const ok = await updateNotificationPreference(pref.type, value) const ok = await updateNotificationPreference(pref.type, value)
if (ok) { if (ok) {
@@ -634,6 +653,15 @@ const togglePref = async (pref, value) => {
} }
} }
const toggleEmailPref = async (pref, value) => {
const ok = await updateEmailNotificationPreference(pref.type, value)
if (ok) {
pref.enabled = value
} else {
toast.error('操作失败')
}
}
const markRead = async (id) => { const markRead = async (id) => {
markNotificationRead(id) markNotificationRead(id)
if (selectedTab.value === 'unread') { if (selectedTab.value === 'unread') {
@@ -729,6 +757,7 @@ onActivated(async () => {
page.value = 0 page.value = 0
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' }) await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
fetchPrefs() fetchPrefs()
fetchEmailPrefs()
}) })
</script> </script>

View File

@@ -116,6 +116,43 @@ export async function updateNotificationPreference(type, enabled) {
} }
} }
export async function fetchEmailNotificationPreferences() {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return []
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return await res.json()
} catch (e) {
return []
}
}
export async function updateEmailNotificationPreference(type, enabled) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, enabled }),
})
return res.ok
} catch (e) {
return false
}
}
/** /**
* 处理信息的高阶函数 * 处理信息的高阶函数
* @returns * @returns