fix: 后端代码格式化

This commit is contained in:
Tim
2025-09-18 14:42:25 +08:00
parent 70f7442f0c
commit 72b2b82e02
325 changed files with 15341 additions and 12370 deletions

View File

@@ -4,53 +4,53 @@ import com.openisle.exception.NotFoundException;
import com.openisle.model.*;
import com.openisle.repository.ActivityRepository;
import com.openisle.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ActivityService {
private final ActivityRepository activityRepository;
private final UserRepository userRepository;
private final LevelService levelService;
private final NotificationService notificationService;
public List<Activity> list() {
return activityRepository.findAll();
}
private final ActivityRepository activityRepository;
private final UserRepository userRepository;
private final LevelService levelService;
private final NotificationService notificationService;
public Activity getByType(ActivityType type) {
Activity a = activityRepository.findByType(type);
if (a == null) throw new NotFoundException("Activity not found");
return a;
}
public List<Activity> list() {
return activityRepository.findAll();
}
public long countLevel1Users() {
int threshold = levelService.nextLevelExp(0);
return userRepository.countByExperienceGreaterThanEqual(threshold);
}
public Activity getByType(ActivityType type) {
Activity a = activityRepository.findByType(type);
if (a == null) throw new NotFoundException("Activity not found");
return a;
}
public void end(Activity activity) {
activity.setEnded(true);
activityRepository.save(activity);
}
public long countLevel1Users() {
int threshold = levelService.nextLevelExp(0);
return userRepository.countByExperienceGreaterThanEqual(threshold);
}
public long countParticipants(Activity activity) {
return activity.getParticipants().size();
}
public void end(Activity activity) {
activity.setEnded(true);
activityRepository.save(activity);
}
/**
* Redeem an activity for the given user.
*
* @return true if the user redeemed for the first time, false if the
* information was simply updated
*/
public boolean redeem(Activity activity, User user, String contact) {
notificationService.createActivityRedeemNotifications(user, contact);
boolean added = activity.getParticipants().add(user);
activityRepository.save(activity);
return added;
}
public long countParticipants(Activity activity) {
return activity.getParticipants().size();
}
/**
* Redeem an activity for the given user.
*
* @return true if the user redeemed for the first time, false if the
* information was simply updated
*/
public boolean redeem(Activity activity, User user, String contact) {
notificationService.createActivityRedeemNotifications(user, contact);
boolean added = activity.getParticipants().add(user);
activityRepository.save(activity);
return added;
}
}

View File

@@ -4,51 +4,55 @@ import com.openisle.model.AiFormatUsage;
import com.openisle.model.User;
import com.openisle.repository.AiFormatUsageRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@Service
@RequiredArgsConstructor
public class AiUsageService {
private final AiFormatUsageRepository usageRepository;
private final UserRepository userRepository;
@Value("${app.ai.format-limit:3}")
private int formatLimit;
private final AiFormatUsageRepository usageRepository;
private final UserRepository userRepository;
public int getFormatLimit() {
return formatLimit;
}
@Value("${app.ai.format-limit:3}")
private int formatLimit;
public void setFormatLimit(int formatLimit) {
this.formatLimit = formatLimit;
}
public int getFormatLimit() {
return formatLimit;
}
public int incrementAndGetCount(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
LocalDate today = LocalDate.now();
AiFormatUsage usage = usageRepository.findByUserAndUseDate(user, today)
.orElseGet(() -> {
AiFormatUsage u = new AiFormatUsage();
u.setUser(user);
u.setUseDate(today);
u.setCount(0);
return u;
});
usage.setCount(usage.getCount() + 1);
usageRepository.save(usage);
return usage.getCount();
}
public void setFormatLimit(int formatLimit) {
this.formatLimit = formatLimit;
}
public int getCount(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return usageRepository.findByUserAndUseDate(user, LocalDate.now())
.map(AiFormatUsage::getCount)
.orElse(0);
}
public int incrementAndGetCount(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
LocalDate today = LocalDate.now();
AiFormatUsage usage = usageRepository
.findByUserAndUseDate(user, today)
.orElseGet(() -> {
AiFormatUsage u = new AiFormatUsage();
u.setUser(user);
u.setUseDate(today);
u.setCount(0);
return u;
});
usage.setCount(usage.getCount() + 1);
usageRepository.save(usage);
return usage.getCount();
}
public int getCount(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return usageRepository
.findByUserAndUseDate(user, LocalDate.now())
.map(AiFormatUsage::getCount)
.orElse(0);
}
}

View File

@@ -6,7 +6,7 @@ import lombok.Value;
/** Result for OAuth authentication indicating whether a new user was created. */
@Value
public class AuthResult {
User user;
boolean newUser;
}
User user;
boolean newUser;
}

View File

@@ -1,25 +1,24 @@
package com.openisle.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class AvatarGenerator {
@Value("${app.avatar.base-url}")
private String baseUrl;
@Value("${app.avatar.base-url}")
private String baseUrl;
@Value("${app.avatar.style}")
private String style;
@Value("${app.avatar.style}")
private String style;
@Value("${app.avatar.size}")
private int size;
@Value("${app.avatar.size}")
private int size;
public String generate(String seed) {
String encoded = URLEncoder.encode(seed, StandardCharsets.UTF_8);
return String.format("%s/%s/png?seed=%s&size=%d", baseUrl, style, encoded, size);
}
public String generate(String seed) {
String encoded = URLEncoder.encode(seed, StandardCharsets.UTF_8);
return String.format("%s/%s/png?seed=%s&size=%d", baseUrl, style, encoded, size);
}
}

View File

@@ -4,11 +4,12 @@ package com.openisle.service;
* Abstract service for verifying CAPTCHA tokens.
*/
public abstract class CaptchaService {
/**
* Verify the CAPTCHA token sent from client.
*
* @param token CAPTCHA token
* @return true if token is valid
*/
public abstract boolean verify(String token);
/**
* Verify the CAPTCHA token sent from client.
*
* @param token CAPTCHA token
* @return true if token is valid
*/
public abstract boolean verify(String token);
}

View File

@@ -3,77 +3,85 @@ package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Category;
import com.openisle.repository.CategoryRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category createCategory(String name, String description, String icon, String smallIcon) {
Category category = new Category();
category.setName(name);
category.setDescription(description);
category.setIcon(icon);
category.setSmallIcon(smallIcon);
return categoryRepository.save(category);
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
if (name != null) {
category.setName(name);
}
if (description != null) {
category.setDescription(description);
}
if (icon != null) {
category.setIcon(icon);
}
if (smallIcon != null) {
category.setSmallIcon(smallIcon);
}
return categoryRepository.save(category);
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
}
public Category getCategory(Long id) {
return categoryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
}
private final CategoryRepository categoryRepository;
/**
* 该方法每次首页加载都会访问,加入缓存
* @return
*/
@Cacheable(
value = CachingConfig.CATEGORY_CACHE_NAME,
key = "'listCategories:'"
)
public List<Category> listCategories() {
return categoryRepository.findAll();
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category createCategory(String name, String description, String icon, String smallIcon) {
Category category = new Category();
category.setName(name);
category.setDescription(description);
category.setIcon(icon);
category.setSmallIcon(smallIcon);
return categoryRepository.save(category);
}
/**
* 获取检索用的分类Id列表
* @param categoryIds
* @param categoryId
* @return
*/
public List<Long> getSearchCategoryIds(List<Long> categoryIds, Long categoryId){
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = List.of(categoryId);
}
return ids;
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category updateCategory(
Long id,
String name,
String description,
String icon,
String smallIcon
) {
Category category = categoryRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
if (name != null) {
category.setName(name);
}
if (description != null) {
category.setDescription(description);
}
if (icon != null) {
category.setIcon(icon);
}
if (smallIcon != null) {
category.setSmallIcon(smallIcon);
}
return categoryRepository.save(category);
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
}
public Category getCategory(Long id) {
return categoryRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
}
/**
* 该方法每次首页加载都会访问,加入缓存
* @return
*/
@Cacheable(value = CachingConfig.CATEGORY_CACHE_NAME, key = "'listCategories:'")
public List<Category> listCategories() {
return categoryRepository.findAll();
}
/**
* 获取检索用的分类Id列表
* @param categoryIds
* @param categoryId
* @return
*/
public List<Long> getSearchCategoryIds(List<Long> categoryIds, Long categoryId) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = List.of(categoryId);
}
return ids;
}
}

View File

@@ -11,88 +11,102 @@ import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ChannelService {
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<ChannelDto> listChannels(Long userId) {
List<MessageConversation> channels = conversationRepository.findByChannelTrue();
return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<ChannelDto> listChannels(Long userId) {
List<MessageConversation> channels = conversationRepository.findByChannelTrue();
return channels
.stream()
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
@Transactional
public ChannelDto joinChannel(Long channelId, Long userId) {
MessageConversation channel = conversationRepository
.findById(channelId)
.orElseThrow(() -> new IllegalArgumentException("Channel not found"));
User user = userRepository
.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
participantRepository
.findByConversationIdAndUserId(channelId, userId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(channel);
p.setUser(user);
MessageParticipant saved = participantRepository.save(p);
channel.getParticipants().add(saved);
return saved;
});
return toDto(channel, userId);
}
private ChannelDto toDto(MessageConversation channel, Long userId) {
ChannelDto dto = new ChannelDto();
dto.setId(channel.getId());
dto.setName(channel.getName());
dto.setDescription(channel.getDescription());
dto.setAvatar(channel.getAvatar());
if (channel.getLastMessage() != null) {
dto.setLastMessage(toMessageDto(channel.getLastMessage()));
}
@Transactional
public ChannelDto joinChannel(Long channelId, Long userId) {
MessageConversation channel = conversationRepository.findById(channelId)
.orElseThrow(() -> new IllegalArgumentException("Channel not found"));
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
participantRepository.findByConversationIdAndUserId(channelId, userId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(channel);
p.setUser(user);
MessageParticipant saved = participantRepository.save(p);
channel.getParticipants().add(saved);
return saved;
});
return toDto(channel, userId);
dto.setMemberCount(channel.getParticipants().size());
boolean joined = channel
.getParticipants()
.stream()
.anyMatch(p -> p.getUser().getId().equals(userId));
dto.setJoined(joined);
if (joined) {
MessageParticipant participant = channel
.getParticipants()
.stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElse(null);
LocalDateTime lastRead = participant.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: participant.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(
channel.getId(),
lastRead,
userId
);
dto.setUnreadCount(unread);
} else {
dto.setUnreadCount(0);
}
return dto;
}
private ChannelDto toDto(MessageConversation channel, Long userId) {
ChannelDto dto = new ChannelDto();
dto.setId(channel.getId());
dto.setName(channel.getName());
dto.setDescription(channel.getDescription());
dto.setAvatar(channel.getAvatar());
if (channel.getLastMessage() != null) {
dto.setLastMessage(toMessageDto(channel.getLastMessage()));
}
dto.setMemberCount(channel.getParticipants().size());
boolean joined = channel.getParticipants().stream()
.anyMatch(p -> p.getUser().getId().equals(userId));
dto.setJoined(joined);
if (joined) {
MessageParticipant participant = channel.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst().orElse(null);
LocalDateTime lastRead = participant.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: participant.getLastReadAt();
long unread = messageRepository
.countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId);
dto.setUnreadCount(unread);
} else {
dto.setUnreadCount(0);
}
return dto;
}
private MessageDto toMessageDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
private MessageDto toMessageDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userDto);
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userDto);
return dto;
}
return dto;
}
}

View File

@@ -1,364 +1,441 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.exception.RateLimitException;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import com.openisle.model.CommentSort;
import com.openisle.model.NotificationType;
import com.openisle.model.PointHistory;
import com.openisle.model.CommentSort;
import com.openisle.model.Post;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService;
import com.openisle.service.PointService;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import com.openisle.service.SubscriptionService;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class CommentService {
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final PointService pointService;
private final ImageUploader imageUploader;
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public Comment addComment(String username, Long postId, String content) {
log.debug("addComment called by user {} for post {}", username, postId);
long recent = commentRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(1));
if (recent >= 3) {
log.debug("Rate limit exceeded for user {}", username);
throw new RateLimitException("Too many comments");
}
User author = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment();
comment.setAuthor(author);
comment.setPost(post);
comment.setContent(content);
comment = commentRepository.save(comment);
log.debug("Comment {} saved for post {}", comment.getId(), postId);
// Update post comment statistics
updatePostCommentStats(post);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
null, null, null, null);
}
for (User u : subscriptionService.getPostSubscribers(postId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
null, null);
}
}
notificationService.notifyMentions(content, author, post, comment);
log.debug("addComment finished for comment {}", comment.getId());
return comment;
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final PointService pointService;
private final ImageUploader imageUploader;
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public Comment addComment(String username, Long postId, String content) {
log.debug("addComment called by user {} for post {}", username, postId);
long recent = commentRepository.countByAuthorAfter(
username,
java.time.LocalDateTime.now().minusMinutes(1)
);
if (recent >= 3) {
log.debug("Rate limit exceeded for user {}", username);
throw new RateLimitException("Too many comments");
}
User author = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment();
comment.setAuthor(author);
comment.setPost(post);
comment.setContent(content);
comment = commentRepository.save(comment);
log.debug("Comment {} saved for post {}", comment.getId(), postId);
// Update post comment statistics
updatePostCommentStats(post);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(
post.getAuthor(),
NotificationType.COMMENT_REPLY,
post,
comment,
null,
null,
null,
null
);
}
for (User u : subscriptionService.getPostSubscribers(postId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(
u,
NotificationType.POST_UPDATED,
post,
comment,
null,
null,
null,
null
);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(
u,
NotificationType.USER_ACTIVITY,
post,
comment,
null,
null,
null,
null
);
}
}
notificationService.notifyMentions(content, author, post, comment);
log.debug("addComment finished for comment {}", comment.getId());
return comment;
}
public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) {
// 根据用户id查询该用户最后回复时间
return commentRepository.findLastCommentTimeOfUserByUserId(userId);
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public Comment addReply(String username, Long parentId, String content) {
log.debug("addReply called by user {} for parent comment {}", username, parentId);
long recent = commentRepository.countByAuthorAfter(
username,
java.time.LocalDateTime.now().minusMinutes(1)
);
if (recent >= 3) {
log.debug("Rate limit exceeded for user {}", username);
throw new RateLimitException("Too many comments");
}
User author = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment parent = commentRepository
.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (parent.getPost().isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment();
comment.setAuthor(author);
comment.setPost(parent.getPost());
comment.setParent(parent);
comment.setContent(content);
comment = commentRepository.save(comment);
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
// Update post comment statistics
updatePostCommentStats(parent.getPost());
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(
parent.getAuthor(),
NotificationType.COMMENT_REPLY,
parent.getPost(),
comment,
null,
null,
null,
null
);
}
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(
u,
NotificationType.COMMENT_REPLY,
parent.getPost(),
comment,
null,
null,
null,
null
);
}
}
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(
u,
NotificationType.POST_UPDATED,
parent.getPost(),
comment,
null,
null,
null,
null
);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(
u,
NotificationType.USER_ACTIVITY,
parent.getPost(),
comment,
null,
null,
null,
null
);
}
}
notificationService.notifyMentions(content, author, parent.getPost(), comment);
log.debug("addReply finished for comment {}", comment.getId());
return comment;
}
public List<Comment> getCommentsForPost(Long postId, CommentSort sort) {
log.debug("getCommentsForPost called for post {} with sort {}", postId, sort);
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
java.util.List<Comment> pinned = new java.util.ArrayList<>();
java.util.List<Comment> others = new java.util.ArrayList<>();
for (Comment c : list) {
if (c.getPinnedAt() != null) {
pinned.add(c);
} else {
others.add(c);
}
}
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
if (sort == CommentSort.NEWEST) {
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
}
java.util.List<Comment> result = new java.util.ArrayList<>();
result.addAll(pinned);
result.addAll(others);
log.debug("getCommentsForPost returning {} comments", result.size());
return result;
}
public List<Comment> getReplies(Long parentId) {
log.debug("getReplies called for parent {}", parentId);
Comment parent = commentRepository
.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(parent);
log.debug("getReplies returning {} replies for parent {}", replies.size(), parentId);
return replies;
}
public List<Comment> getRecentCommentsByUser(String username, int limit) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
List<Comment> comments = commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable);
log.debug(
"getRecentCommentsByUser returning {} comments for user {}",
comments.size(),
username
);
return comments;
}
public java.util.List<User> getParticipants(Long postId, int limit) {
log.debug("getParticipants called for post {} with limit {}", postId, limit);
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.util.LinkedHashSet<User> set = new java.util.LinkedHashSet<>();
set.add(post.getAuthor());
set.addAll(commentRepository.findDistinctAuthorsByPost(post));
java.util.List<User> list = new java.util.ArrayList<>(set);
java.util.List<User> result = list.subList(0, Math.min(limit, list.size()));
log.debug("getParticipants returning {} users for post {}", result.size(), postId);
return result;
}
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
log.debug("getCommentsByIds called for ids {}", ids);
java.util.List<Comment> comments = commentRepository.findAllById(ids);
log.debug("getCommentsByIds returning {} comments", comments.size());
return comments;
}
public java.time.LocalDateTime getLastCommentTime(Long postId) {
log.debug("getLastCommentTime called for post {}", postId);
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.time.LocalDateTime time = commentRepository.findLastCommentTime(post);
log.debug("getLastCommentTime for post {} is {}", postId, time);
return time;
}
public long countComments(Long postId) {
log.debug("countComments called for post {}", postId);
long count = commentRepository.countByPostId(postId);
log.debug("countComments for post {} is {}", postId, count);
return count;
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public void deleteComment(String username, Long id) {
log.debug("deleteComment called by user {} for comment {}", username, id);
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment comment = commentRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (!user.getId().equals(comment.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
log.debug("User {} not authorized to delete comment {}", username, id);
throw new IllegalArgumentException("Unauthorized");
}
deleteCommentCascade(comment);
log.debug("deleteComment completed for comment {}", id);
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public void deleteCommentCascade(Comment comment) {
log.debug("deleteCommentCascade called for comment {}", comment.getId());
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(comment);
for (Comment c : replies) {
deleteCommentCascade(c);
}
public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) { // 根据用户id查询该用户最后回复时间
return commentRepository.findLastCommentTimeOfUserByUserId(userId);
// 逻辑删除相关的积分历史记录,并收集受影响的用户
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
// 收集需要重新计算积分的用户
Set<User> usersToRecalculate = pointHistories
.stream()
.map(PointHistory::getUser)
.collect(Collectors.toSet());
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository
.findByComment(comment)
.forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
Post post = comment.getPost();
commentRepository.delete(comment);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);
// Update post comment statistics
updatePostCommentStats(post);
// 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) {
for (User user : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(user);
user.setPoint(newPoints);
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public Comment addReply(String username, Long parentId, String content) {
log.debug("addReply called by user {} for parent comment {}", username, parentId);
long recent = commentRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(1));
if (recent >= 3) {
log.debug("Rate limit exceeded for user {}", username);
throw new RateLimitException("Too many comments");
}
User author = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment parent = commentRepository.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (parent.getPost().isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment();
comment.setAuthor(author);
comment.setPost(parent.getPost());
comment.setParent(parent);
comment.setContent(content);
comment = commentRepository.save(comment);
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
// Update post comment statistics
updatePostCommentStats(parent.getPost());
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
comment, null, null, null, null);
}
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
null, null, null, null);
}
}
notificationService.notifyMentions(content, author, parent.getPost(), comment);
log.debug("addReply finished for comment {}", comment.getId());
return comment;
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}
@Transactional
public Comment pinComment(String username, Long id) {
Comment c = commentRepository
.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(LocalDateTime.now());
return commentRepository.save(c);
}
public List<Comment> getCommentsForPost(Long postId, CommentSort sort) {
log.debug("getCommentsForPost called for post {} with sort {}", postId, sort);
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
java.util.List<Comment> pinned = new java.util.ArrayList<>();
java.util.List<Comment> others = new java.util.ArrayList<>();
for (Comment c : list) {
if (c.getPinnedAt() != null) {
pinned.add(c);
} else {
others.add(c);
}
}
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
if (sort == CommentSort.NEWEST) {
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
}
java.util.List<Comment> result = new java.util.ArrayList<>();
result.addAll(pinned);
result.addAll(others);
log.debug("getCommentsForPost returning {} comments", result.size());
return result;
@Transactional
public Comment unpinComment(String username, Long id) {
Comment c = commentRepository
.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(null);
return commentRepository.save(c);
}
public List<Comment> getReplies(Long parentId) {
log.debug("getReplies called for parent {}", parentId);
Comment parent = commentRepository.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(parent);
log.debug("getReplies returning {} replies for parent {}", replies.size(), parentId);
return replies;
private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
return reactions + replies;
}
/**
* Update post comment statistics (comment count and last reply time)
*/
public void updatePostCommentStats(Post post) {
long commentCount = commentRepository.countByPostId(post.getId());
post.setCommentCount(commentCount);
LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post);
if (lastReplyAt == null) {
post.setLastReplyAt(post.getCreatedAt());
} else {
post.setLastReplyAt(lastReplyAt);
}
postRepository.save(post);
public List<Comment> getRecentCommentsByUser(String username, int limit) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
List<Comment> comments = commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable);
log.debug("getRecentCommentsByUser returning {} comments for user {}", comments.size(), username);
return comments;
}
public java.util.List<User> getParticipants(Long postId, int limit) {
log.debug("getParticipants called for post {} with limit {}", postId, limit);
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.util.LinkedHashSet<User> set = new java.util.LinkedHashSet<>();
set.add(post.getAuthor());
set.addAll(commentRepository.findDistinctAuthorsByPost(post));
java.util.List<User> list = new java.util.ArrayList<>(set);
java.util.List<User> result = list.subList(0, Math.min(limit, list.size()));
log.debug("getParticipants returning {} users for post {}", result.size(), postId);
return result;
}
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
log.debug("getCommentsByIds called for ids {}", ids);
java.util.List<Comment> comments = commentRepository.findAllById(ids);
log.debug("getCommentsByIds returning {} comments", comments.size());
return comments;
}
public java.time.LocalDateTime getLastCommentTime(Long postId) {
log.debug("getLastCommentTime called for post {}", postId);
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.time.LocalDateTime time = commentRepository.findLastCommentTime(post);
log.debug("getLastCommentTime for post {} is {}", postId, time);
return time;
}
public long countComments(Long postId) {
log.debug("countComments called for post {}", postId);
long count = commentRepository.countByPostId(postId);
log.debug("countComments for post {} is {}", postId, count);
return count;
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public void deleteComment(String username, Long id) {
log.debug("deleteComment called by user {} for comment {}", username, id);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment comment = commentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (!user.getId().equals(comment.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
log.debug("User {} not authorized to delete comment {}", username, id);
throw new IllegalArgumentException("Unauthorized");
}
deleteCommentCascade(comment);
log.debug("deleteComment completed for comment {}", id);
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public void deleteCommentCascade(Comment comment) {
log.debug("deleteCommentCascade called for comment {}", comment.getId());
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(comment);
for (Comment c : replies) {
deleteCommentCascade(c);
}
// 逻辑删除相关的积分历史记录,并收集受影响的用户
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
// 收集需要重新计算积分的用户
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
Post post = comment.getPost();
commentRepository.delete(comment);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);
// Update post comment statistics
updatePostCommentStats(post);
// 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) {
for (User user : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(user);
user.setPoint(newPoints);
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}
@Transactional
public Comment pinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(LocalDateTime.now());
return commentRepository.save(c);
}
@Transactional
public Comment unpinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(null);
return commentRepository.save(c);
}
private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
return reactions + replies;
}
/**
* Update post comment statistics (comment count and last reply time)
*/
public void updatePostCommentStats(Post post) {
long commentCount = commentRepository.countByPostId(post.getId());
post.setCommentCount(commentCount);
LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post);
if (lastReplyAt == null) {
post.setLastReplyAt(post.getCreatedAt());
} else {
post.setLastReplyAt(lastReplyAt);
}
postRepository.save(post);
log.debug("Updated post {} stats: commentCount={}, lastReplyAt={}",
post.getId(), commentCount, lastReplyAt);
}
log.debug(
"Updated post {} stats: commentCount={}, lastReplyAt={}",
post.getId(),
commentCount,
lastReplyAt
);
}
}

View File

@@ -3,6 +3,8 @@ package com.openisle.service;
import com.openisle.model.ContributorConfig;
import com.openisle.repository.ContributorConfigRepository;
import jakarta.annotation.PostConstruct;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -10,80 +12,82 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContributorService {
private static final String OWNER = "nagisa77";
private static final String REPO = "OpenIsle";
private final ContributorConfigRepository repository;
private final RestTemplate restTemplate = new RestTemplate();
private static final String OWNER = "nagisa77";
private static final String REPO = "OpenIsle";
@PostConstruct
@Scheduled(cron = "0 0 * * * *")
public void updateContributions() {
for (ContributorConfig config : repository.findAll()) {
long lines = fetchContributionLines(config.getGithubId());
if (lines != -1) {
config.setContributionLines(lines);
repository.save(config);
}
}
private final ContributorConfigRepository repository;
private final RestTemplate restTemplate = new RestTemplate();
@PostConstruct
@Scheduled(cron = "0 0 * * * *")
public void updateContributions() {
for (ContributorConfig config : repository.findAll()) {
long lines = fetchContributionLines(config.getGithubId());
if (lines != -1) {
config.setContributionLines(lines);
repository.save(config);
}
}
}
private long fetchContributionLines(String githubId) {
try {
String url = String.format("https://api.github.com/repos/%s/%s/stats/contributors", OWNER, REPO);
ResponseEntity<?> response = restTemplate.getForEntity(url, Object.class);
private long fetchContributionLines(String githubId) {
try {
String url = String.format(
"https://api.github.com/repos/%s/%s/stats/contributors",
OWNER,
REPO
);
ResponseEntity<?> response = restTemplate.getForEntity(url, Object.class);
// 检查是否为202GitHub有时会返回202表示正在生成统计数据
if (response.getStatusCodeValue() == 202) {
log.warn("GitHub API 返回202统计数据正在生成中githubId: {}", githubId);
return -1;
}
// 检查是否为202GitHub有时会返回202表示正在生成统计数据
if (response.getStatusCodeValue() == 202) {
log.warn("GitHub API 返回202统计数据正在生成中githubId: {}", githubId);
return -1;
}
Object body = response.getBody();
if (!(body instanceof List)) {
// 不是List类型直接返回0
return 0;
}
List<?> listBody = (List<?>) body;
for (Object itemObj : listBody) {
if (!(itemObj instanceof Map)) continue;
Map<String, Object> item = (Map<String, Object>) itemObj;
Map<String, Object> author = (Map<String, Object>) item.get("author");
if (author != null && githubId.equals(author.get("login"))) {
List<Map<String, Object>> weeks = (List<Map<String, Object>>) item.get("weeks");
long total = 0;
if (weeks != null) {
for (Map<String, Object> week : weeks) {
Number a = (Number) week.get("a");
Number d = (Number) week.get("d");
if (a != null) {
total += a.longValue();
}
if (d != null) {
total += d.longValue();
}
}
}
return total;
}
}
} catch (Exception e) {
log.warn(e.getMessage());
}
Object body = response.getBody();
if (!(body instanceof List)) {
// 不是List类型直接返回0
return 0;
}
List<?> listBody = (List<?>) body;
for (Object itemObj : listBody) {
if (!(itemObj instanceof Map)) continue;
Map<String, Object> item = (Map<String, Object>) itemObj;
Map<String, Object> author = (Map<String, Object>) item.get("author");
if (author != null && githubId.equals(author.get("login"))) {
List<Map<String, Object>> weeks = (List<Map<String, Object>>) item.get("weeks");
long total = 0;
if (weeks != null) {
for (Map<String, Object> week : weeks) {
Number a = (Number) week.get("a");
Number d = (Number) week.get("d");
if (a != null) {
total += a.longValue();
}
if (d != null) {
total += d.longValue();
}
}
}
return total;
}
}
} catch (Exception e) {
log.warn(e.getMessage());
}
return 0;
}
public long getContributionLines(String userIname) {
return repository.findByUserIname(userIname)
.map(ContributorConfig::getContributionLines)
.orElse(0L);
}
public long getContributionLines(String userIname) {
return repository
.findByUserIname(userIname)
.map(ContributorConfig::getContributionLines)
.orElse(0L);
}
}

View File

@@ -4,22 +4,21 @@ import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.http.HttpMethodName;
import com.qcloud.cos.model.GeneratePresignedUrlRequest;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.region.Region;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.stereotype.Service;
/**
* ImageUploader implementation using Tencent Cloud COS.
@@ -27,98 +26,107 @@ import java.util.concurrent.Executors;
@Service
public class CosImageUploader extends ImageUploader {
private final COSClient cosClient;
private final String bucketName;
private final String baseUrl;
private static final String UPLOAD_DIR = "dynamic_assert/";
private static final Logger logger = LoggerFactory.getLogger(CosImageUploader.class);
private final ExecutorService executor = Executors.newFixedThreadPool(2,
new CustomizableThreadFactory("cos-upload-"));
private final COSClient cosClient;
private final String bucketName;
private final String baseUrl;
private static final String UPLOAD_DIR = "dynamic_assert/";
private static final Logger logger = LoggerFactory.getLogger(CosImageUploader.class);
private final ExecutorService executor = Executors.newFixedThreadPool(
2,
new CustomizableThreadFactory("cos-upload-")
);
@org.springframework.beans.factory.annotation.Autowired
public CosImageUploader(
com.openisle.repository.ImageRepository imageRepository,
@Value("${cos.secret-id:}") String secretId,
@Value("${cos.secret-key:}") String secretKey,
@Value("${cos.region:ap-guangzhou}") String region,
@Value("${cos.bucket-name:}") String bucketName,
@Value("${cos.base-url:https://example.com}") String baseUrl) {
super(imageRepository, baseUrl);
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
ClientConfig config = new ClientConfig(new Region(region));
this.cosClient = new COSClient(cred, config);
this.bucketName = bucketName;
this.baseUrl = baseUrl;
logger.debug("COS client initialized for region {} with bucket {}", region, bucketName);
}
@org.springframework.beans.factory.annotation.Autowired
public CosImageUploader(
com.openisle.repository.ImageRepository imageRepository,
@Value("${cos.secret-id:}") String secretId,
@Value("${cos.secret-key:}") String secretKey,
@Value("${cos.region:ap-guangzhou}") String region,
@Value("${cos.bucket-name:}") String bucketName,
@Value("${cos.base-url:https://example.com}") String baseUrl
) {
super(imageRepository, baseUrl);
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
ClientConfig config = new ClientConfig(new Region(region));
this.cosClient = new COSClient(cred, config);
this.bucketName = bucketName;
this.baseUrl = baseUrl;
logger.debug("COS client initialized for region {} with bucket {}", region, bucketName);
}
// for tests
CosImageUploader(COSClient cosClient,
com.openisle.repository.ImageRepository imageRepository,
String bucketName,
String baseUrl) {
super(imageRepository, baseUrl);
this.cosClient = cosClient;
this.bucketName = bucketName;
this.baseUrl = baseUrl;
logger.debug("COS client provided directly with bucket {}", bucketName);
}
// for tests
CosImageUploader(
COSClient cosClient,
com.openisle.repository.ImageRepository imageRepository,
String bucketName,
String baseUrl
) {
super(imageRepository, baseUrl);
this.cosClient = cosClient;
this.bucketName = bucketName;
this.baseUrl = baseUrl;
logger.debug("COS client provided directly with bucket {}", bucketName);
}
@Override
protected CompletableFuture<String> doUpload(byte[] data, String filename) {
return CompletableFuture.supplyAsync(() -> {
logger.debug("Uploading {} bytes as {}", data.length, filename);
String ext = "";
int dot = filename.lastIndexOf('.');
if (dot != -1) {
ext = filename.substring(dot);
}
String randomName = UUID.randomUUID().toString().replace("-", "") + ext;
String objectKey = UPLOAD_DIR + randomName;
logger.debug("Generated object key {}", objectKey);
ObjectMetadata meta = new ObjectMetadata();
meta.setContentLength(data.length);
PutObjectRequest req = new PutObjectRequest(
bucketName,
objectKey,
new ByteArrayInputStream(data),
meta);
logger.debug("Sending PutObject request to bucket {}", bucketName);
cosClient.putObject(req);
String url = baseUrl + "/" + objectKey;
logger.debug("Upload successful, accessible at {}", url);
return url;
}, executor);
}
@Override
protected void deleteFromStore(String key) {
try {
cosClient.deleteObject(bucketName, key);
} catch (Exception e) {
logger.warn("Failed to delete image {} from COS", key, e);
}
}
@Override
public java.util.Map<String, String> presignUpload(String filename) {
@Override
protected CompletableFuture<String> doUpload(byte[] data, String filename) {
return CompletableFuture.supplyAsync(
() -> {
logger.debug("Uploading {} bytes as {}", data.length, filename);
String ext = "";
int dot = filename.lastIndexOf('.');
if (dot != -1) {
ext = filename.substring(dot);
ext = filename.substring(dot);
}
String randomName = java.util.UUID.randomUUID().toString().replace("-", "") + ext;
String randomName = UUID.randomUUID().toString().replace("-", "") + ext;
String objectKey = UPLOAD_DIR + randomName;
java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + 15 * 60 * 1000L);
GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucketName, objectKey, HttpMethodName.PUT);
req.setExpiration(expiration);
java.net.URL url = cosClient.generatePresignedUrl(req);
String fileUrl = baseUrl + "/" + objectKey;
return java.util.Map.of(
"uploadUrl", url.toString(),
"fileUrl", fileUrl,
"key", objectKey
logger.debug("Generated object key {}", objectKey);
ObjectMetadata meta = new ObjectMetadata();
meta.setContentLength(data.length);
PutObjectRequest req = new PutObjectRequest(
bucketName,
objectKey,
new ByteArrayInputStream(data),
meta
);
logger.debug("Sending PutObject request to bucket {}", bucketName);
cosClient.putObject(req);
String url = baseUrl + "/" + objectKey;
logger.debug("Upload successful, accessible at {}", url);
return url;
},
executor
);
}
@Override
protected void deleteFromStore(String key) {
try {
cosClient.deleteObject(bucketName, key);
} catch (Exception e) {
logger.warn("Failed to delete image {} from COS", key, e);
}
}
@Override
public java.util.Map<String, String> presignUpload(String filename) {
String ext = "";
int dot = filename.lastIndexOf('.');
if (dot != -1) {
ext = filename.substring(dot);
}
String randomName = java.util.UUID.randomUUID().toString().replace("-", "") + ext;
String objectKey = UPLOAD_DIR + randomName;
java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + 15 * 60 * 1000L);
GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(
bucketName,
objectKey,
HttpMethodName.PUT
);
req.setExpiration(expiration);
java.net.URL url = cosClient.generatePresignedUrl(req);
String fileUrl = baseUrl + "/" + objectKey;
return java.util.Map.of("uploadUrl", url.toString(), "fileUrl", fileUrl, "key", objectKey);
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
@@ -12,96 +13,123 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class DiscordAuthService {
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
@Value("${discord.client-id:}")
private String clientId;
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
@Value("${discord.client-secret:}")
private String clientSecret;
@Value("${discord.client-id:}")
private String clientId;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
try {
String tokenUrl = "https://discord.com/api/oauth2/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
@Value("${discord.client-secret:}")
private String clientSecret;
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("grant_type", "authorization_code");
body.add("code", code);
if (redirectUri != null) {
body.add("redirect_uri", redirectUri);
}
body.add("scope", "identify email");
public Optional<AuthResult> authenticate(
String code,
com.openisle.model.RegisterMode mode,
String redirectUri,
boolean viaInvite
) {
try {
String tokenUrl = "https://discord.com/api/oauth2/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class);
if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) {
return Optional.empty();
}
String accessToken = tokenRes.getBody().get("access_token").asText();
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
"https://discord.com/api/users/@me", HttpMethod.GET, entity, JsonNode.class);
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
return Optional.empty();
}
JsonNode userNode = userRes.getBody();
String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null;
String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null;
String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null;
String avatar = null;
if (userNode.hasNonNull("avatar") && id != null) {
avatar = "https://cdn.discordapp.com/avatars/" + id + "/" + userNode.get("avatar").asText() + ".png";
}
if (email == null) {
email = (username != null ? username : id) + "@users.noreply.discord.com";
}
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("grant_type", "authorization_code");
body.add("code", code);
if (redirectUri != null) {
body.add("redirect_uri", redirectUri);
}
body.add("scope", "identify email");
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(
tokenUrl,
request,
JsonNode.class
);
if (
!tokenRes.getStatusCode().is2xxSuccessful() ||
tokenRes.getBody() == null ||
!tokenRes.getBody().has("access_token")
) {
return Optional.empty();
}
String accessToken = tokenRes.getBody().get("access_token").asText();
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
"https://discord.com/api/users/@me",
HttpMethod.GET,
entity,
JsonNode.class
);
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
return Optional.empty();
}
JsonNode userNode = userRes.getBody();
String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null;
String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null;
String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null;
String avatar = null;
if (userNode.hasNonNull("avatar") && id != null) {
avatar =
"https://cdn.discordapp.com/avatars/" +
id +
"/" +
userNode.get("avatar").asText() +
".png";
}
if (email == null) {
email = (username != null ? username : id) + "@users.noreply.discord.com";
}
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
private AuthResult processUser(
String email,
String username,
String avatar,
com.openisle.model.RegisterMode mode,
boolean viaInvite
) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
}
return new AuthResult(userRepository.save(user), true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -9,68 +9,79 @@ import com.openisle.repository.DraftRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.ImageUploader;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class DraftService {
private final DraftRepository draftRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final ImageUploader imageUploader;
@Transactional
public Draft saveDraft(String username, Long categoryId, String title, String content, List<Long> tagIds) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Draft draft = draftRepository.findByAuthor(user).orElse(new Draft());
String oldContent = draft.getContent();
boolean existing = draft.getId() != null;
draft.setAuthor(user);
draft.setTitle(title);
draft.setContent(content);
if (categoryId != null) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
draft.setCategory(category);
} else {
draft.setCategory(null);
}
Set<Tag> tags = new HashSet<>();
if (tagIds != null && !tagIds.isEmpty()) {
tags.addAll(tagRepository.findAllById(tagIds));
}
draft.setTags(tags);
Draft saved = draftRepository.save(draft);
if (existing) {
imageUploader.adjustReferences(oldContent == null ? "" : oldContent, content);
} else {
imageUploader.addReferences(imageUploader.extractUrls(content));
}
return saved;
}
private final DraftRepository draftRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final ImageUploader imageUploader;
@Transactional(readOnly = true)
public Optional<Draft> getDraft(String username) {
return userRepository.findByUsername(username)
.flatMap(draftRepository::findByAuthor);
@Transactional
public Draft saveDraft(
String username,
Long categoryId,
String title,
String content,
List<Long> tagIds
) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Draft draft = draftRepository.findByAuthor(user).orElse(new Draft());
String oldContent = draft.getContent();
boolean existing = draft.getId() != null;
draft.setAuthor(user);
draft.setTitle(title);
draft.setContent(content);
if (categoryId != null) {
Category category = categoryRepository
.findById(categoryId)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
draft.setCategory(category);
} else {
draft.setCategory(null);
}
Set<Tag> tags = new HashSet<>();
if (tagIds != null && !tagIds.isEmpty()) {
tags.addAll(tagRepository.findAllById(tagIds));
}
draft.setTags(tags);
Draft saved = draftRepository.save(draft);
if (existing) {
imageUploader.adjustReferences(oldContent == null ? "" : oldContent, content);
} else {
imageUploader.addReferences(imageUploader.extractUrls(content));
}
return saved;
}
@Transactional
public void deleteDraft(String username) {
userRepository.findByUsername(username).ifPresent(user ->
draftRepository.findByAuthor(user).ifPresent(draft -> {
imageUploader.removeReferences(imageUploader.extractUrls(draft.getContent()));
draftRepository.delete(draft);
})
);
}
@Transactional(readOnly = true)
public Optional<Draft> getDraft(String username) {
return userRepository.findByUsername(username).flatMap(draftRepository::findByAuthor);
}
@Transactional
public void deleteDraft(String username) {
userRepository
.findByUsername(username)
.ifPresent(user ->
draftRepository
.findByAuthor(user)
.ifPresent(draft -> {
imageUploader.removeReferences(imageUploader.extractUrls(draft.getContent()));
draftRepository.delete(draft);
})
);
}
}

View File

@@ -4,11 +4,12 @@ package com.openisle.service;
* Abstract email sender used to deliver emails.
*/
public abstract class EmailSender {
/**
* Send an email to a recipient.
* @param to recipient email address
* @param subject email subject
* @param text email body
*/
public abstract void sendEmail(String to, String subject, String text);
/**
* Send an email to a recipient.
* @param to recipient email address
* @param subject email subject
* @param text email body
*/
public abstract void sendEmail(String to, String subject, String text);
}

View File

@@ -4,123 +4,156 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.AvatarGenerator;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.client.HttpClientErrorException;
import com.openisle.service.AvatarGenerator;
import java.util.Collections;
import java.util.Map;
import java.util.HashMap;
import java.util.Optional;
import org.springframework.web.client.RestTemplate;
@Service
@RequiredArgsConstructor
public class GithubAuthService {
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
private final AvatarGenerator avatarGenerator;
@Value("${github.client-id:}")
private String clientId;
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
private final AvatarGenerator avatarGenerator;
@Value("${github.client-secret:}")
private String clientSecret;
@Value("${github.client-id:}")
private String clientId;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
@Value("${github.client-secret:}")
private String clientSecret;
public Optional<AuthResult> authenticate(
String code,
com.openisle.model.RegisterMode mode,
String redirectUri,
boolean viaInvite
) {
try {
String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = new HashMap<>();
body.put("client_id", clientId);
body.put("client_secret", clientSecret);
body.put("code", code);
if (redirectUri != null) {
body.put("redirect_uri", redirectUri);
}
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(
tokenUrl,
request,
JsonNode.class
);
if (
!tokenRes.getStatusCode().is2xxSuccessful() ||
tokenRes.getBody() == null ||
!tokenRes.getBody().has("access_token")
) {
return Optional.empty();
}
String accessToken = tokenRes.getBody().get("access_token").asText();
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
authHeaders.set(HttpHeaders.USER_AGENT, "OpenIsle");
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
"https://api.github.com/user",
HttpMethod.GET,
entity,
JsonNode.class
);
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
return Optional.empty();
}
JsonNode userNode = userRes.getBody();
String username = userNode.hasNonNull("login") ? userNode.get("login").asText() : null;
String avatarUrl = userNode.hasNonNull("avatar_url")
? userNode.get("avatar_url").asText()
: null;
String email = null;
if (userNode.hasNonNull("email")) {
email = userNode.get("email").asText();
}
if (email == null || email.isEmpty()) {
try {
String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = new HashMap<>();
body.put("client_id", clientId);
body.put("client_secret", clientSecret);
body.put("code", code);
if (redirectUri != null) {
body.put("redirect_uri", redirectUri);
ResponseEntity<JsonNode> emailsRes = restTemplate.exchange(
"https://api.github.com/user/emails",
HttpMethod.GET,
entity,
JsonNode.class
);
if (
emailsRes.getStatusCode().is2xxSuccessful() &&
emailsRes.getBody() != null &&
emailsRes.getBody().isArray()
) {
for (JsonNode n : emailsRes.getBody()) {
if (n.has("primary") && n.get("primary").asBoolean()) {
email = n.get("email").asText();
break;
}
}
HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<JsonNode> tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class);
if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) {
return Optional.empty();
}
String accessToken = tokenRes.getBody().get("access_token").asText();
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
authHeaders.set(HttpHeaders.USER_AGENT, "OpenIsle");
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);
ResponseEntity<JsonNode> userRes = restTemplate.exchange(
"https://api.github.com/user", HttpMethod.GET, entity, JsonNode.class);
if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) {
return Optional.empty();
}
JsonNode userNode = userRes.getBody();
String username = userNode.hasNonNull("login") ? userNode.get("login").asText() : null;
String avatarUrl = userNode.hasNonNull("avatar_url") ? userNode.get("avatar_url").asText() : null;
String email = null;
if (userNode.hasNonNull("email")) {
email = userNode.get("email").asText();
}
if (email == null || email.isEmpty()) {
try {
ResponseEntity<JsonNode> emailsRes = restTemplate.exchange(
"https://api.github.com/user/emails", HttpMethod.GET, entity, JsonNode.class);
if (emailsRes.getStatusCode().is2xxSuccessful() && emailsRes.getBody() != null && emailsRes.getBody().isArray()) {
for (JsonNode n : emailsRes.getBody()) {
if (n.has("primary") && n.get("primary").asBoolean()) {
email = n.get("email").asText();
break;
}
}
}
} catch (HttpClientErrorException ignored) {
// ignore when the email API is not accessible
}
}
if (email == null) {
email = username + "@users.noreply.github.com";
}
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
} catch (HttpClientErrorException ignored) {
// ignore when the email API is not accessible
}
}
if (email == null) {
email = username + "@users.noreply.github.com";
}
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
private AuthResult processUser(
String email,
String username,
String avatar,
com.openisle.model.RegisterMode mode,
boolean viaInvite
) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -7,72 +7,84 @@ import com.google.api.client.json.jackson2.JacksonFactory;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.AvatarGenerator;
import java.util.Collections;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.openisle.service.AvatarGenerator;
import java.util.Collections;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class GoogleAuthService {
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
@Value("${google.client-id:}")
private String clientId;
@Value("${google.client-id:}")
private String clientId;
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
.setAudience(Collections.singletonList(clientId))
.build();
try {
GoogleIdToken idToken = verifier.verify(idTokenString);
if (idToken == null) {
return Optional.empty();
}
GoogleIdToken.Payload payload = idToken.getPayload();
String email = payload.getEmail();
String name = (String) payload.get("name");
String picture = (String) payload.get("picture");
return Optional.of(processUser(email, name, picture, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
public Optional<AuthResult> authenticate(
String idTokenString,
com.openisle.model.RegisterMode mode,
boolean viaInvite
) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(),
new JacksonFactory()
)
.setAudience(Collections.singletonList(clientId))
.build();
try {
GoogleIdToken idToken = verifier.verify(idTokenString);
if (idToken == null) {
return Optional.empty();
}
GoogleIdToken.Payload payload = idToken.getPayload();
String email = payload.getEmail();
String name = (String) payload.get("name");
String picture = (String) payload.get("picture");
return Optional.of(processUser(email, name, picture, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
User user = new User();
String baseUsername = email.split("@")[0];
String username = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(username).isPresent()) {
username = baseUsername + suffix++;
}
user.setUsername(username);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
private AuthResult processUser(
String email,
String name,
String avatar,
com.openisle.model.RegisterMode mode,
boolean viaInvite
) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(username));
}
return new AuthResult(userRepository.save(user), true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
User user = new User();
String baseUsername = email.split("@")[0];
String username = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(username).isPresent()) {
username = baseUsername + suffix++;
}
user.setUsername(username);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(username));
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -2,7 +2,6 @@ package com.openisle.service;
import com.openisle.model.Image;
import com.openisle.repository.ImageRepository;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -13,94 +12,102 @@ import java.util.regex.Pattern;
* Abstract service for uploading images and tracking their references.
*/
public abstract class ImageUploader {
private final ImageRepository imageRepository;
private final String baseUrl;
private final Pattern urlPattern;
protected ImageUploader(ImageRepository imageRepository, String baseUrl) {
this.imageRepository = imageRepository;
if (baseUrl.endsWith("/")) {
this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
private final ImageRepository imageRepository;
private final String baseUrl;
private final Pattern urlPattern;
protected ImageUploader(ImageRepository imageRepository, String baseUrl) {
this.imageRepository = imageRepository;
if (baseUrl.endsWith("/")) {
this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
} else {
this.baseUrl = baseUrl;
}
this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+");
}
/**
* Upload an image asynchronously and return a future of its accessible URL.
*/
public CompletableFuture<String> upload(byte[] data, String filename) {
return doUpload(data, filename).thenApply(url -> url);
}
protected abstract CompletableFuture<String> doUpload(byte[] data, String filename);
protected abstract void deleteFromStore(String key);
/**
* Generate a presigned PUT URL for direct browser upload.
* Default implementation is unsupported.
*/
public java.util.Map<String, String> presignUpload(String filename) {
throw new UnsupportedOperationException("presignUpload not supported");
}
/** Extract COS URLs from text. */
public Set<String> extractUrls(String text) {
Set<String> set = new HashSet<>();
if (text == null) return set;
Matcher m = urlPattern.matcher(text);
while (m.find()) {
set.add(m.group());
}
return set;
}
public void addReferences(Set<String> urls) {
for (String u : urls) addReference(u);
}
public void removeReferences(Set<String> urls) {
for (String u : urls) removeReference(u);
}
public void adjustReferences(String oldText, String newText) {
Set<String> oldUrls = extractUrls(oldText);
Set<String> newUrls = extractUrls(newText);
for (String u : newUrls) {
if (!oldUrls.contains(u)) addReference(u);
}
for (String u : oldUrls) {
if (!newUrls.contains(u)) removeReference(u);
}
}
private void addReference(String url) {
if (!url.startsWith(baseUrl)) return;
imageRepository
.findByUrl(url)
.ifPresentOrElse(
img -> {
img.setRefCount(img.getRefCount() + 1);
imageRepository.save(img);
},
() -> {
Image img = new Image();
img.setUrl(url);
img.setRefCount(1);
imageRepository.save(img);
}
);
}
private void removeReference(String url) {
if (!url.startsWith(baseUrl)) return;
imageRepository
.findByUrl(url)
.ifPresent(img -> {
long count = img.getRefCount() - 1;
if (count <= 0) {
imageRepository.delete(img);
String key = url.substring(baseUrl.length() + 1);
deleteFromStore(key);
} else {
this.baseUrl = baseUrl;
img.setRefCount(count);
imageRepository.save(img);
}
this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+");
}
/**
* Upload an image asynchronously and return a future of its accessible URL.
*/
public CompletableFuture<String> upload(byte[] data, String filename) {
return doUpload(data, filename).thenApply(url -> url);
}
protected abstract CompletableFuture<String> doUpload(byte[] data, String filename);
protected abstract void deleteFromStore(String key);
/**
* Generate a presigned PUT URL for direct browser upload.
* Default implementation is unsupported.
*/
public java.util.Map<String, String> presignUpload(String filename) {
throw new UnsupportedOperationException("presignUpload not supported");
}
/** Extract COS URLs from text. */
public Set<String> extractUrls(String text) {
Set<String> set = new HashSet<>();
if (text == null) return set;
Matcher m = urlPattern.matcher(text);
while (m.find()) {
set.add(m.group());
}
return set;
}
public void addReferences(Set<String> urls) {
for (String u : urls) addReference(u);
}
public void removeReferences(Set<String> urls) {
for (String u : urls) removeReference(u);
}
public void adjustReferences(String oldText, String newText) {
Set<String> oldUrls = extractUrls(oldText);
Set<String> newUrls = extractUrls(newText);
for (String u : newUrls) {
if (!oldUrls.contains(u)) addReference(u);
}
for (String u : oldUrls) {
if (!newUrls.contains(u)) removeReference(u);
}
}
private void addReference(String url) {
if (!url.startsWith(baseUrl)) return;
imageRepository.findByUrl(url).ifPresentOrElse(img -> {
img.setRefCount(img.getRefCount() + 1);
imageRepository.save(img);
}, () -> {
Image img = new Image();
img.setUrl(url);
img.setRefCount(1);
imageRepository.save(img);
});
}
private void removeReference(String url) {
if (!url.startsWith(baseUrl)) return;
imageRepository.findByUrl(url).ifPresent(img -> {
long count = img.getRefCount() - 1;
if (count <= 0) {
imageRepository.delete(img);
String key = url.substring(baseUrl.length() + 1);
deleteFromStore(key);
} else {
img.setRefCount(count);
imageRepository.save(img);
}
});
}
});
}
}

View File

@@ -4,81 +4,88 @@ import com.openisle.model.InviteToken;
import com.openisle.model.User;
import com.openisle.repository.InviteTokenRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDate;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InviteService {
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
private final JwtService jwtService;
private final PointService pointService;
@Value
public class InviteValidateResult {
InviteToken inviteToken;
boolean validate;
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
private final JwtService jwtService;
private final PointService pointService;
@Value
public class InviteValidateResult {
InviteToken inviteToken;
boolean validate;
}
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(
inviter,
today
);
if (existing.isPresent()) {
InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null
? inviteToken.getShortToken()
: inviteToken.getToken();
}
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
}
String token = jwtService.generateInviteToken(username);
String shortToken;
do {
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} while (inviteTokenRepository.existsByShortToken(shortToken));
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.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return shortToken;
}
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return shortToken;
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
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 {
jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
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();
}
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
try {
jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
}
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository
.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
}
}

View File

@@ -3,120 +3,119 @@ package com.openisle.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Key;
import java.util.Date;
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.reason-secret}")
private String reasonSecret;
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.reset-secret}")
private String resetSecret;
@Value("${app.jwt.reason-secret}")
private String reasonSecret;
@Value("${app.jwt.invite-secret}")
private String inviteSecret;
@Value("${app.jwt.reset-secret}")
private String resetSecret;
@Value("${app.jwt.expiration}")
private long expiration;
@Value("${app.jwt.invite-secret}")
private String inviteSecret;
private Key getSigningKeyForSecret(String signSecret) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = digest.digest(signSecret.getBytes(StandardCharsets.UTF_8));
return Keys.hmacShaKeyFor(keyBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
@Value("${app.jwt.expiration}")
private long expiration;
private Key getSigningKeyForSecret(String signSecret) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = digest.digest(signSecret.getBytes(StandardCharsets.UTF_8));
return Keys.hmacShaKeyFor(keyBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
public String generateToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(secret))
.compact();
}
public String generateToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(secret))
.compact();
}
public String generateReasonToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(reasonSecret))
.compact();
}
public String generateReasonToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(reasonSecret))
.compact();
}
public String generateResetToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(resetSecret))
.compact();
}
public String generateResetToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(resetSecret))
.compact();
}
public String generateInviteToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(inviteSecret))
.compact();
}
public String generateInviteToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(inviteSecret))
.compact();
}
public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(secret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(secret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForReason(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(reasonSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForReason(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(reasonSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForReset(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(resetSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForReset(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(resetSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForInvite(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(inviteSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForInvite(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(inviteSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}

View File

@@ -1,90 +1,92 @@
package com.openisle.service;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ExperienceLogRepository;
import com.openisle.model.ExperienceLog;
import com.openisle.model.User;
import com.openisle.repository.ExperienceLogRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@Service
@RequiredArgsConstructor
public class LevelService {
private final UserRepository userRepository;
// repositories for experience-related entities
private final ExperienceLogRepository experienceLogRepository;
private final UserVisitService userVisitService;
private static final int[] LEVEL_EXP = {100,200,300,600,1200,10000};
private final UserRepository userRepository;
// repositories for experience-related entities
private final ExperienceLogRepository experienceLogRepository;
private final UserVisitService userVisitService;
private ExperienceLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return experienceLogRepository.findByUserAndLogDate(user, today)
.orElseGet(() -> {
ExperienceLog log = new ExperienceLog();
log.setUser(user);
log.setLogDate(today);
log.setPostCount(0);
log.setCommentCount(0);
log.setReactionCount(0);
return experienceLogRepository.save(log);
});
private static final int[] LEVEL_EXP = { 100, 200, 300, 600, 1200, 10000 };
private ExperienceLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return experienceLogRepository
.findByUserAndLogDate(user, today)
.orElseGet(() -> {
ExperienceLog log = new ExperienceLog();
log.setUser(user);
log.setLogDate(today);
log.setPostCount(0);
log.setCommentCount(0);
log.setReactionCount(0);
return experienceLogRepository.save(log);
});
}
private int addExperience(User user, int amount) {
user.setExperience(user.getExperience() + amount);
userRepository.save(user);
return amount;
}
public int awardForPost(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
experienceLogRepository.save(log);
return addExperience(user, 30);
}
public int awardForComment(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getCommentCount() > 3) return 0;
log.setCommentCount(log.getCommentCount() + 1);
experienceLogRepository.save(log);
return addExperience(user, 10);
}
public int awardForReaction(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getReactionCount() > 3) return 0;
log.setReactionCount(log.getReactionCount() + 1);
experienceLogRepository.save(log);
return addExperience(user, 5);
}
public int awardForSignin(String username) {
boolean first = userVisitService.recordVisit(username);
if (!first) return 0;
User user = userRepository.findByUsername(username).orElseThrow();
return addExperience(user, 5);
}
public int getLevel(int exp) {
int level = 0;
for (int t : LEVEL_EXP) {
if (exp >= t) level++;
else break;
}
return level;
}
private int addExperience(User user, int amount) {
user.setExperience(user.getExperience() + amount);
userRepository.save(user);
return amount;
}
public int awardForPost(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
experienceLogRepository.save(log);
return addExperience(user,30);
}
public int awardForComment(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getCommentCount() > 3) return 0;
log.setCommentCount(log.getCommentCount() + 1);
experienceLogRepository.save(log);
return addExperience(user,10);
}
public int awardForReaction(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
ExperienceLog log = getTodayLog(user);
if (log.getReactionCount() > 3) return 0;
log.setReactionCount(log.getReactionCount() + 1);
experienceLogRepository.save(log);
return addExperience(user,5);
}
public int awardForSignin(String username) {
boolean first = userVisitService.recordVisit(username);
if (!first) return 0;
User user = userRepository.findByUsername(username).orElseThrow();
return addExperience(user,5);
}
public int getLevel(int exp) {
int level = 0;
for (int t : LEVEL_EXP) {
if (exp >= t) level++; else break;
}
return level;
}
public int nextLevelExp(int exp) {
for (int t : LEVEL_EXP) {
if (exp < t) return t;
}
return LEVEL_EXP[LEVEL_EXP.length-1];
public int nextLevelExp(int exp) {
for (int t : LEVEL_EXP) {
if (exp < t) return t;
}
return LEVEL_EXP[LEVEL_EXP.length - 1];
}
}

View File

@@ -2,188 +2,201 @@ package com.openisle.service;
import com.openisle.dto.CommentMedalDto;
import com.openisle.dto.ContributorMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.dto.MedalDto;
import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.PostMedalDto;
import com.openisle.dto.SeedUserMedalDto;
import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.model.MedalType;
import com.openisle.model.User;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MedalService {
private static final long COMMENT_TARGET = 100;
private static final long POST_TARGET = 100;
private static final LocalDateTime SEED_USER_DEADLINE = LocalDateTime.of(2025, 9, 16, 0, 0);
private static final long CONTRIBUTION_TARGET = 1;
private static final long PIONEER_LIMIT = 1000;
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private final ContributorService contributorService;
private static final long COMMENT_TARGET = 100;
private static final long POST_TARGET = 100;
private static final LocalDateTime SEED_USER_DEADLINE = LocalDateTime.of(2025, 9, 16, 0, 0);
private static final long CONTRIBUTION_TARGET = 1;
private static final long PIONEER_LIMIT = 1000;
public List<MedalDto> getMedals(Long userId) {
List<MedalDto> medals = new ArrayList<>();
User user = null;
if (userId != null) {
user = userRepository.findById(userId).orElse(null);
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private final ContributorService contributorService;
public List<MedalDto> getMedals(Long userId) {
List<MedalDto> medals = new ArrayList<>();
User user = null;
if (userId != null) {
user = userRepository.findById(userId).orElse(null);
}
MedalType selected = user != null ? user.getDisplayMedal() : null;
CommentMedalDto commentMedal = new CommentMedalDto();
commentMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_comment.png"
);
commentMedal.setTitle("评论达人");
commentMedal.setDescription("评论超过100条");
commentMedal.setType(MedalType.COMMENT);
commentMedal.setTargetCommentCount(COMMENT_TARGET);
if (user != null) {
long count = commentRepository.countByAuthor_Id(userId);
commentMedal.setCurrentCommentCount(count);
commentMedal.setCompleted(count >= COMMENT_TARGET);
} else {
commentMedal.setCurrentCommentCount(0);
commentMedal.setCompleted(false);
}
commentMedal.setSelected(selected == MedalType.COMMENT);
medals.add(commentMedal);
PostMedalDto postMedal = new PostMedalDto();
postMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_post.png"
);
postMedal.setTitle("发帖达人");
postMedal.setDescription("发帖超过100条");
postMedal.setType(MedalType.POST);
postMedal.setTargetPostCount(POST_TARGET);
if (user != null) {
long count = postRepository.countByAuthor_Id(userId);
postMedal.setCurrentPostCount(count);
postMedal.setCompleted(count >= POST_TARGET);
} else {
postMedal.setCurrentPostCount(0);
postMedal.setCompleted(false);
}
postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal);
FeaturedMedalDto featuredMedal = new FeaturedMedalDto();
featuredMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png"
);
featuredMedal.setTitle("精选作者");
featuredMedal.setDescription("至少有1篇文章被收录为精选");
featuredMedal.setType(MedalType.FEATURED);
featuredMedal.setTargetFeaturedCount(1);
if (user != null) {
long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId());
featuredMedal.setCurrentFeaturedCount(count);
featuredMedal.setCompleted(count >= 1);
} else {
featuredMedal.setCurrentFeaturedCount(0);
featuredMedal.setCompleted(false);
}
featuredMedal.setSelected(selected == MedalType.FEATURED);
medals.add(featuredMedal);
ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"
);
contributorMedal.setTitle("贡献者");
contributorMedal.setDescription("对仓库贡献超过1行代码");
contributorMedal.setType(MedalType.CONTRIBUTOR);
contributorMedal.setTargetContributionLines(CONTRIBUTION_TARGET);
if (user != null) {
long lines = contributorService.getContributionLines(user.getUsername());
contributorMedal.setCurrentContributionLines(lines);
contributorMedal.setCompleted(lines >= CONTRIBUTION_TARGET);
} else {
contributorMedal.setCurrentContributionLines(0);
contributorMedal.setCompleted(false);
}
contributorMedal.setSelected(selected == MedalType.CONTRIBUTOR);
medals.add(contributorMedal);
SeedUserMedalDto seedUserMedal = new SeedUserMedalDto();
seedUserMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_seed.png"
);
seedUserMedal.setTitle("种子用户");
seedUserMedal.setDescription("2025.9.16前注册的用户");
seedUserMedal.setType(MedalType.SEED);
if (user != null) {
seedUserMedal.setRegisterDate(user.getCreatedAt());
seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE));
} else {
seedUserMedal.setCompleted(false);
}
seedUserMedal.setSelected(selected == MedalType.SEED);
medals.add(seedUserMedal);
PioneerMedalDto pioneerMedal = new PioneerMedalDto();
pioneerMedal.setIcon(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_pioneer.png"
);
pioneerMedal.setTitle("开山鼻祖");
pioneerMedal.setDescription("前1000位加入的用户");
pioneerMedal.setType(MedalType.PIONEER);
if (user != null) {
long rank = userRepository.countByCreatedAtBefore(user.getCreatedAt()) + 1;
pioneerMedal.setRank(rank);
pioneerMedal.setCompleted(rank <= PIONEER_LIMIT);
} else {
pioneerMedal.setCompleted(false);
}
pioneerMedal.setSelected(selected == MedalType.PIONEER);
medals.add(pioneerMedal);
if (user != null && selected == null) {
for (MedalDto medal : medals) {
if (medal.isCompleted()) {
medal.setSelected(true);
user.setDisplayMedal(medal.getType());
userRepository.save(user);
break;
}
MedalType selected = user != null ? user.getDisplayMedal() : null;
CommentMedalDto commentMedal = new CommentMedalDto();
commentMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_comment.png");
commentMedal.setTitle("评论达人");
commentMedal.setDescription("评论超过100条");
commentMedal.setType(MedalType.COMMENT);
commentMedal.setTargetCommentCount(COMMENT_TARGET);
if (user != null) {
long count = commentRepository.countByAuthor_Id(userId);
commentMedal.setCurrentCommentCount(count);
commentMedal.setCompleted(count >= COMMENT_TARGET);
} else {
commentMedal.setCurrentCommentCount(0);
commentMedal.setCompleted(false);
}
commentMedal.setSelected(selected == MedalType.COMMENT);
medals.add(commentMedal);
PostMedalDto postMedal = new PostMedalDto();
postMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_post.png");
postMedal.setTitle("发帖达人");
postMedal.setDescription("发帖超过100条");
postMedal.setType(MedalType.POST);
postMedal.setTargetPostCount(POST_TARGET);
if (user != null) {
long count = postRepository.countByAuthor_Id(userId);
postMedal.setCurrentPostCount(count);
postMedal.setCompleted(count >= POST_TARGET);
} else {
postMedal.setCurrentPostCount(0);
postMedal.setCompleted(false);
}
postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal);
FeaturedMedalDto featuredMedal = new FeaturedMedalDto();
featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png");
featuredMedal.setTitle("精选作者");
featuredMedal.setDescription("至少有1篇文章被收录为精选");
featuredMedal.setType(MedalType.FEATURED);
featuredMedal.setTargetFeaturedCount(1);
if (user != null) {
long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId());
featuredMedal.setCurrentFeaturedCount(count);
featuredMedal.setCompleted(count >= 1);
} else {
featuredMedal.setCurrentFeaturedCount(0);
featuredMedal.setCompleted(false);
}
featuredMedal.setSelected(selected == MedalType.FEATURED);
medals.add(featuredMedal);
ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
contributorMedal.setTitle("贡献者");
contributorMedal.setDescription("对仓库贡献超过1行代码");
contributorMedal.setType(MedalType.CONTRIBUTOR);
contributorMedal.setTargetContributionLines(CONTRIBUTION_TARGET);
if (user != null) {
long lines = contributorService.getContributionLines(user.getUsername());
contributorMedal.setCurrentContributionLines(lines);
contributorMedal.setCompleted(lines >= CONTRIBUTION_TARGET);
} else {
contributorMedal.setCurrentContributionLines(0);
contributorMedal.setCompleted(false);
}
contributorMedal.setSelected(selected == MedalType.CONTRIBUTOR);
medals.add(contributorMedal);
SeedUserMedalDto seedUserMedal = new SeedUserMedalDto();
seedUserMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_seed.png");
seedUserMedal.setTitle("种子用户");
seedUserMedal.setDescription("2025.9.16前注册的用户");
seedUserMedal.setType(MedalType.SEED);
if (user != null) {
seedUserMedal.setRegisterDate(user.getCreatedAt());
seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE));
} else {
seedUserMedal.setCompleted(false);
}
seedUserMedal.setSelected(selected == MedalType.SEED);
medals.add(seedUserMedal);
PioneerMedalDto pioneerMedal = new PioneerMedalDto();
pioneerMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_pioneer.png");
pioneerMedal.setTitle("开山鼻祖");
pioneerMedal.setDescription("前1000位加入的用户");
pioneerMedal.setType(MedalType.PIONEER);
if (user != null) {
long rank = userRepository.countByCreatedAtBefore(user.getCreatedAt()) + 1;
pioneerMedal.setRank(rank);
pioneerMedal.setCompleted(rank <= PIONEER_LIMIT);
} else {
pioneerMedal.setCompleted(false);
}
pioneerMedal.setSelected(selected == MedalType.PIONEER);
medals.add(pioneerMedal);
if (user != null && selected == null) {
for (MedalDto medal : medals) {
if (medal.isCompleted()) {
medal.setSelected(true);
user.setDisplayMedal(medal.getType());
userRepository.save(user);
break;
}
}
}
return medals;
}
}
public void ensureDisplayMedal(User user) {
if (user == null || user.getDisplayMedal() != null) {
return;
}
if (commentRepository.countByAuthor_Id(user.getId()) >= COMMENT_TARGET) {
user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {
user.setDisplayMedal(MedalType.PIONEER);
} else if (user.getCreatedAt().isBefore(SEED_USER_DEADLINE)) {
user.setDisplayMedal(MedalType.SEED);
}
if (user.getDisplayMedal() != null) {
userRepository.save(user);
}
}
return medals;
}
public void selectMedal(String username, MedalType type) {
User user = userRepository.findByUsername(username).orElseThrow();
boolean completed = getMedals(user.getId()).stream()
.filter(m -> m.getType() == type)
.findFirst()
.map(MedalDto::isCompleted)
.orElse(false);
if (!completed) {
throw new IllegalArgumentException("Medal not completed");
}
user.setDisplayMedal(type);
userRepository.save(user);
public void ensureDisplayMedal(User user) {
if (user == null || user.getDisplayMedal() != null) {
return;
}
if (commentRepository.countByAuthor_Id(user.getId()) >= COMMENT_TARGET) {
user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {
user.setDisplayMedal(MedalType.PIONEER);
} else if (user.getCreatedAt().isBefore(SEED_USER_DEADLINE)) {
user.setDisplayMedal(MedalType.SEED);
}
if (user.getDisplayMedal() != null) {
userRepository.save(user);
}
}
public void selectMedal(String username, MedalType type) {
User user = userRepository.findByUsername(username).orElseThrow();
boolean completed = getMedals(user.getId())
.stream()
.filter(m -> m.getType() == type)
.findFirst()
.map(MedalDto::isCompleted)
.orElse(false);
if (!completed) {
throw new IllegalArgumentException("Medal not completed");
}
user.setDisplayMedal(type);
userRepository.save(user);
}
}

View File

@@ -1,22 +1,27 @@
package com.openisle.service;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.User;
import com.openisle.model.Reaction;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.MessageNotificationPayload;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
import com.openisle.dto.MessageNotificationPayload;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.Reaction;
import com.openisle.model.User;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@@ -24,326 +29,403 @@ import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class MessageService {
private final MessageRepository messageRepository;
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final NotificationProducer notificationProducer;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
private final MessageRepository messageRepository;
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final NotificationProducer notificationProducer;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
User recipient = userRepository.findById(recipientId)
.orElseThrow(() -> new IllegalArgumentException("Recipient not found"));
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
User sender = userRepository
.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
User recipient = userRepository
.findById(recipientId)
.orElseThrow(() -> new IllegalArgumentException("Recipient not found"));
log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername());
MessageConversation conversation = findOrCreateConversation(sender, recipient);
log.info("Conversation found or created with ID: {}", conversation.getId());
log.info(
"Finding or creating conversation for users {} and {}",
sender.getUsername(),
recipient.getUsername()
);
MessageConversation conversation = findOrCreateConversation(sender, recipient);
log.info("Conversation found or created with ID: {}", conversation.getId());
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
log.info("Message saved with ID: {}", message.getId());
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository
.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
log.info("Message saved with ID: {}", message.getId());
conversation.setLastMessage(message);
conversationRepository.save(conversation);
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
conversation.setLastMessage(message);
conversationRepository.save(conversation);
log.info(
"Conversation {} updated with last message ID {}",
conversation.getId(),
message.getId()
);
try {
MessageDto messageDto = toDto(message);
long unreadCount = getUnreadMessageCount(recipientId);
try {
MessageDto messageDto = toDto(message);
// 创建包含对话和参与者信息的完整payload
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", conversation.getParticipants().stream()
.map(p -> {
Map<String, Object> participantInfo = new HashMap<>();
participantInfo.put("userId", p.getUser().getId());
participantInfo.put("username", p.getUser().getUsername());
return participantInfo;
}).collect(Collectors.toList()));
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("unreadCount", unreadCount);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
if (notificationProducer != null) {
log.info("NotificationProducer is available");
} else {
log.info("ERROR: NotificationProducer is NULL!");
return message;
}
log.info("Recipient username: {}", recipient.getUsername());
notificationProducer.sendNotification(new MessageNotificationPayload(recipient.getUsername(), combinedPayload));
log.info("=== Notification call completed ===");
} catch (Exception e) {
log.error("=== Error in notification process ===", e);
}
long unreadCount = getUnreadMessageCount(recipientId);
// 创建包含对话和参与者信息的完整payload
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put(
"participants",
conversation
.getParticipants()
.stream()
.map(p -> {
Map<String, Object> participantInfo = new HashMap<>();
participantInfo.put("userId", p.getUser().getId());
participantInfo.put("username", p.getUser().getUsername());
return participantInfo;
})
.collect(Collectors.toList())
);
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("unreadCount", unreadCount);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
if (notificationProducer != null) {
log.info("NotificationProducer is available");
} else {
log.info("ERROR: NotificationProducer is NULL!");
return message;
}
log.info("Recipient username: {}", recipient.getUsername());
notificationProducer.sendNotification(
new MessageNotificationPayload(recipient.getUsername(), combinedPayload)
);
log.info("=== Notification call completed ===");
} catch (Exception e) {
log.error("=== Error in notification process ===", e);
}
@Transactional
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
MessageConversation conversation = conversationRepository.findByIdWithParticipantsAndUsers(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
return message;
}
// Join the conversation if not already a participant (useful for channels)
participantRepository.findByConversationIdAndUserId(conversationId, senderId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(conversation);
p.setUser(sender);
return participantRepository.save(p);
});
@Transactional
public Message sendMessageToConversation(
Long senderId,
Long conversationId,
String content,
Long replyToId
) {
User sender = userRepository
.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
MessageConversation conversation = conversationRepository
.findByIdWithParticipantsAndUsers(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
// Join the conversation if not already a participant (useful for channels)
participantRepository
.findByConversationIdAndUserId(conversationId, senderId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(conversation);
p.setUser(sender);
return participantRepository.save(p);
});
conversation.setLastMessage(message);
conversationRepository.save(conversation);
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository
.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
MessageDto messageDto = toDto(message);
conversation.setLastMessage(message);
conversationRepository.save(conversation);
// Build participant payloads once to avoid duplicate broadcasts
java.util.List<Map<String, Object>> participantInfos = conversation.getParticipants().stream()
.filter(p -> !p.getUser().getId().equals(senderId))
.map(p -> {
Map<String, Object> info = new HashMap<>();
info.put("userId", p.getUser().getId());
info.put("username", p.getUser().getUsername());
info.put("unreadCount", getUnreadMessageCount(p.getUser().getId()));
info.put("channelUnread", getUnreadChannelCount(p.getUser().getId()));
return info;
}).collect(Collectors.toList());
MessageDto messageDto = toDto(message);
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", participantInfos);
// Build participant payloads once to avoid duplicate broadcasts
java.util.List<Map<String, Object>> participantInfos = conversation
.getParticipants()
.stream()
.filter(p -> !p.getUser().getId().equals(senderId))
.map(p -> {
Map<String, Object> info = new HashMap<>();
info.put("userId", p.getUser().getId());
info.put("username", p.getUser().getUsername());
info.put("unreadCount", getUnreadMessageCount(p.getUser().getId()));
info.put("channelUnread", getUnreadChannelCount(p.getUser().getId()));
return info;
})
.collect(Collectors.toList());
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", participantInfos);
// Use sender's username for sharding; only one notification is needed
notificationProducer.sendNotification(new MessageNotificationPayload(sender.getUsername(), combinedPayload));
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
return message;
// Use sender's username for sharding; only one notification is needed
notificationProducer.sendNotification(
new MessageNotificationPayload(sender.getUsername(), combinedPayload)
);
return message;
}
public MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
UserSummaryDto userSummaryDto = new UserSummaryDto();
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
Message reply = message.getReplyTo();
MessageDto replyDto = new MessageDto();
replyDto.setId(reply.getId());
replyDto.setContent(reply.getContent());
UserSummaryDto replySender = new UserSummaryDto();
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
public MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
java.util.List<ReactionDto> reactionDtos = reactions
.stream()
.map(reactionMapper::toDto)
.collect(Collectors.toList());
dto.setReactions(reactionDtos);
UserSummaryDto userSummaryDto = new UserSummaryDto();
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userSummaryDto);
return dto;
}
if (message.getReplyTo() != null) {
Message reply = message.getReplyTo();
MessageDto replyDto = new MessageDto();
replyDto.setId(reply.getId());
replyDto.setContent(reply.getContent());
UserSummaryDto replySender = new UserSummaryDto();
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) {
User user1 = userRepository
.findById(user1Id)
.orElseThrow(() -> new IllegalArgumentException("User1 not found"));
User user2 = userRepository
.findById(user2Id)
.orElseThrow(() -> new IllegalArgumentException("User2 not found"));
return findOrCreateConversation(user1, user2);
}
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
java.util.List<ReactionDto> reactionDtos = reactions.stream()
.map(reactionMapper::toDto)
.collect(Collectors.toList());
dto.setReactions(reactionDtos);
private MessageConversation findOrCreateConversation(User user1, User user2) {
log.info(
"Searching for existing conversation between {} and {}",
user1.getUsername(),
user2.getUsername()
);
return conversationRepository
.findConversationsByUsers(user1, user2)
.stream()
.findFirst()
.orElseGet(() -> {
log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation();
conversation = conversationRepository.save(conversation);
log.info("New conversation created with ID: {}", conversation.getId());
return dto;
MessageParticipant participant1 = new MessageParticipant();
participant1.setConversation(conversation);
participant1.setUser(user1);
participantRepository.save(participant1);
log.info(
"Participant {} added to conversation {}",
user1.getUsername(),
conversation.getId()
);
MessageParticipant participant2 = new MessageParticipant();
participant2.setConversation(conversation);
participant2.setUser(user2);
participantRepository.save(participant2);
log.info(
"Participant {} added to conversation {}",
user2.getUsername(),
conversation.getId()
);
return conversation;
});
}
@Transactional(readOnly = true)
public List<ConversationDto> getConversations(Long userId) {
List<MessageConversation> conversations =
conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
return conversations
.stream()
.filter(c -> !c.isChannel())
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
private ConversationDto toDto(MessageConversation conversation, Long userId) {
ConversationDto dto = new ConversationDto();
dto.setId(conversation.getId());
dto.setChannel(conversation.isChannel());
dto.setName(conversation.getName());
dto.setAvatar(conversation.getAvatar());
dto.setCreatedAt(conversation.getCreatedAt());
if (conversation.getLastMessage() != null) {
dto.setLastMessage(toDto(conversation.getLastMessage()));
}
dto.setParticipants(
conversation
.getParticipants()
.stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList())
);
public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) {
User user1 = userRepository.findById(user1Id)
.orElseThrow(() -> new IllegalArgumentException("User1 not found"));
User user2 = userRepository.findById(user2Id)
.orElseThrow(() -> new IllegalArgumentException("User2 not found"));
return findOrCreateConversation(user1, user2);
MessageParticipant self = conversation
.getParticipants()
.stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Participant not found in conversation"));
LocalDateTime lastRead = self.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: self.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(
conversation.getId(),
lastRead,
userId
);
dto.setUnreadCount(unreadCount);
return dto;
}
@Transactional
public ConversationDetailDto getConversationDetails(
Long conversationId,
Long userId,
Pageable pageable
) {
markConversationAsRead(conversationId, userId);
MessageConversation conversation = conversationRepository
.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
Page<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
List<UserSummaryDto> participants = conversation
.getParticipants()
.stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList());
ConversationDetailDto detailDto = new ConversationDetailDto();
detailDto.setId(conversation.getId());
detailDto.setName(conversation.getName());
detailDto.setChannel(conversation.isChannel());
detailDto.setAvatar(conversation.getAvatar());
detailDto.setParticipants(participants);
detailDto.setMessages(messageDtoPage);
return detailDto;
}
@Transactional
public void markConversationAsRead(Long conversationId, Long userId) {
MessageParticipant participant = participantRepository
.findByConversationIdAndUserId(conversationId, userId)
.orElseThrow(() -> new IllegalArgumentException("Participant not found"));
participant.setLastReadAt(LocalDateTime.now());
participantRepository.save(participant);
}
@Transactional(readOnly = true)
public long getUnreadMessageCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0;
for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(
p.getConversation().getId(),
lastRead,
userId
);
}
return totalUnreadCount;
}
private MessageConversation findOrCreateConversation(User user1, User user2) {
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
return conversationRepository.findConversationsByUsers(user1, user2).stream()
.findFirst()
.orElseGet(() -> {
log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation();
conversation = conversationRepository.save(conversation);
log.info("New conversation created with ID: {}", conversation.getId());
MessageParticipant participant1 = new MessageParticipant();
participant1.setConversation(conversation);
participant1.setUser(user1);
participantRepository.save(participant1);
log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId());
MessageParticipant participant2 = new MessageParticipant();
participant2.setConversation(conversation);
participant2.setUser(user2);
participantRepository.save(participant2);
log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId());
return conversation;
});
@Transactional(readOnly = true)
public long getUnreadChannelCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long unreadChannelCount = 0;
for (MessageParticipant p : participations) {
if (!p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: p.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(
p.getConversation().getId(),
lastRead,
userId
);
if (unread > 0) {
unreadChannelCount++;
}
}
@Transactional(readOnly = true)
public List<ConversationDto> getConversations(Long userId) {
List<MessageConversation> conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
return conversations.stream()
.filter(c -> !c.isChannel())
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
private ConversationDto toDto(MessageConversation conversation, Long userId) {
ConversationDto dto = new ConversationDto();
dto.setId(conversation.getId());
dto.setChannel(conversation.isChannel());
dto.setName(conversation.getName());
dto.setAvatar(conversation.getAvatar());
dto.setCreatedAt(conversation.getCreatedAt());
if (conversation.getLastMessage() != null) {
dto.setLastMessage(toDto(conversation.getLastMessage()));
}
dto.setParticipants(conversation.getParticipants().stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList()));
MessageParticipant self = conversation.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Participant not found in conversation"));
LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId);
dto.setUnreadCount(unreadCount);
return dto;
}
@Transactional
public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) {
markConversationAsRead(conversationId, userId);
MessageConversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
Page<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
List<UserSummaryDto> participants = conversation.getParticipants().stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList());
ConversationDetailDto detailDto = new ConversationDetailDto();
detailDto.setId(conversation.getId());
detailDto.setName(conversation.getName());
detailDto.setChannel(conversation.isChannel());
detailDto.setAvatar(conversation.getAvatar());
detailDto.setParticipants(participants);
detailDto.setMessages(messageDtoPage);
return detailDto;
}
@Transactional
public void markConversationAsRead(Long conversationId, Long userId) {
MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId)
.orElseThrow(() -> new IllegalArgumentException("Participant not found"));
participant.setLastReadAt(LocalDateTime.now());
participantRepository.save(participant);
}
@Transactional(readOnly = true)
public long getUnreadMessageCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0;
for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
}
return totalUnreadCount;
}
@Transactional(readOnly = true)
public long getUnreadChannelCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long unreadChannelCount = 0;
for (MessageParticipant p : participations) {
if (!p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
if (unread > 0) {
unreadChannelCount++;
}
}
return unreadChannelCount;
}
}
return unreadChannelCount;
}
}

View File

@@ -15,49 +15,45 @@ import org.springframework.stereotype.Service;
@Slf4j
public class NotificationProducer {
private final RabbitTemplate rabbitTemplate;
private final ShardingStrategy shardingStrategy;
@Value("${rabbitmq.sharding.enabled}")
private boolean shardingEnabled;
private final RabbitTemplate rabbitTemplate;
private final ShardingStrategy shardingStrategy;
public void sendNotification(MessageNotificationPayload payload) {
String targetUsername = payload.getTargetUsername();
try {
if (shardingEnabled) {
// 使用分片策略发送消息
sendShardedNotification(payload, targetUsername);
} else {
// 使用原始单队列方式发送(向后兼容)
sendLegacyNotification(payload);
}
} catch (Exception e) {
log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e);
throw e;
}
@Value("${rabbitmq.sharding.enabled}")
private boolean shardingEnabled;
public void sendNotification(MessageNotificationPayload payload) {
String targetUsername = payload.getTargetUsername();
try {
if (shardingEnabled) {
// 使用分片策略发送消息
sendShardedNotification(payload, targetUsername);
} else {
// 使用原始单队列方式发送(向后兼容)
sendLegacyNotification(payload);
}
} catch (Exception e) {
log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e);
throw e;
}
/**
* 使用分片策略发送消息
*/
private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) {
ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
shardInfo.getRoutingKey(),
payload
);
}
/**
* 使用原始单队列方式发送消息(向后兼容)
*/
private void sendLegacyNotification(MessageNotificationPayload payload) {
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.ROUTING_KEY,
payload
);
}
}
}
/**
* 使用分片策略发送消息
*/
private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) {
ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername);
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, shardInfo.getRoutingKey(), payload);
}
/**
* 使用原始单队列方式发送消息(向后兼容)
*/
private void sendLegacyNotification(MessageNotificationPayload payload) {
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.ROUTING_KEY,
payload
);
}
}

View File

@@ -1,288 +1,378 @@
package com.openisle.service;
import com.openisle.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openisle.dto.NotificationPreferenceDto;
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;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.util.Set;
import java.util.HashSet;
import java.util.EnumSet;
import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.Executor;
/** Service for creating and retrieving notifications. */
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;
private final EmailSender emailSender;
private final PushNotificationService pushNotificationService;
private final ReactionRepository reactionRepository;
private final Executor notificationExecutor;
@Value("${app.website-url}")
private String websiteUrl;
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;
private final EmailSender emailSender;
private final PushNotificationService pushNotificationService;
private final ReactionRepository reactionRepository;
private final Executor notificationExecutor;
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
@Value("${app.website-url}")
private String websiteUrl;
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
NotificationType.COMMENT_REPLY,
NotificationType.LOTTERY_WIN,
NotificationType.LOTTERY_DRAW
);
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
private String buildPayload(String body, String url) {
// Ensure push notifications contain a link to the related resource so
// that verifications can assert its presence and users can navigate
// directly from the notification.
if (url == null || url.isBlank()) {
return body;
}
return body + ", 点击以查看: " + url;
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) {
// Ensure push notifications contain a link to the related resource so
// that verifications can assert its presence and users can navigate
// directly from the notification.
if (url == null || url.isBlank()) {
return body;
}
return body + ", 点击以查看: " + url;
}
public void sendCustomPush(User user, String body, String url) {
pushNotificationService.sendNotification(user, buildPayload(body, url));
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);
}
public Notification createNotification(
User user,
NotificationType type,
Post post,
Comment comment,
Boolean approved,
User fromUser,
ReactionType reactionType,
String content
) {
Notification n = new Notification();
n.setUser(user);
n.setType(type);
n.setPost(post);
n.setComment(comment);
n.setApproved(approved);
n.setFromUser(fromUser);
n.setReactionType(reactionType);
n.setContent(content);
if (type == NotificationType.POST_VIEWED && fromUser != null && post != null) {
notificationRepository.deleteByTypeAndFromUserAndPost(type, fromUser, post);
}
n = notificationRepository.save(n);
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) {
return createNotification(user, type, post, comment, approved, null, null, null);
// Runnable asyncTask = () -> {
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()
);
emailSender.sendEmail(user.getEmail(), "有人回复了你", 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(), "你有新的互动", "你有新的互动, 点击以查看: " + 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(), "你有新的互动", "你有新的互动, 点击以查看: " + url);
// }
// }
}
// };
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved,
User fromUser, ReactionType reactionType, String content) {
Notification n = new Notification();
n.setUser(user);
n.setType(type);
n.setPost(post);
n.setComment(comment);
n.setApproved(approved);
n.setFromUser(fromUser);
n.setReactionType(reactionType);
n.setContent(content);
if (type == NotificationType.POST_VIEWED && fromUser != null && post != null) {
notificationRepository.deleteByTypeAndFromUserAndPost(type, fromUser, post);
}
n = notificationRepository.save(n);
// if (TransactionSynchronizationManager.isSynchronizationActive()) {
// TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
// @Override
// public void afterCommit() {
// notificationExecutor.execute(asyncTask);
// }
// });
// } else {
// notificationExecutor.execute(asyncTask);
// }
// Runnable asyncTask = () -> {
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());
emailSender.sendEmail(user.getEmail(), "有人回复了你", 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(), "你有新的互动", "你有新的互动, 点击以查看: " + 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(), "你有新的互动", "你有新的互动, 点击以查看: " + url);
// }
// }
}
// };
return n;
}
// if (TransactionSynchronizationManager.isSynchronizationActive()) {
// TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
// @Override
// public void afterCommit() {
// notificationExecutor.execute(asyncTask);
// }
// });
// } else {
// notificationExecutor.execute(asyncTask);
// }
return n;
public void deleteReactionNotification(
User fromUser,
Post post,
Comment comment,
ReactionType reactionType
) {
if (post != null) {
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(
NotificationType.REACTION,
fromUser,
post,
reactionType
);
} else if (comment != null) {
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(
NotificationType.REACTION,
fromUser,
comment,
reactionType
);
}
}
public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) {
if (post != null) {
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType);
} else if (comment != null) {
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType);
}
/**
* Create notifications for all admins when a user submits a register request.
* Old register request notifications from the same applicant are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createRegisterRequestNotifications(User applicant, String reason) {
notificationRepository.deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(
admin,
NotificationType.REGISTER_REQUEST,
null,
null,
null,
applicant,
null,
reason
);
}
}
/**
* Create notifications for all admins when a user submits a register request.
* Old register request notifications from the same applicant are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createRegisterRequestNotifications(User applicant, String reason) {
notificationRepository.deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(admin, NotificationType.REGISTER_REQUEST, null, null,
null, applicant, null, reason);
}
/**
* Create notifications for all admins when a user redeems an activity.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createActivityRedeemNotifications(User user, String content) {
notificationRepository.deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(
admin,
NotificationType.ACTIVITY_REDEEM,
null,
null,
null,
user,
null,
content
);
}
}
/**
* Create notifications for all admins when a user redeems an activity.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createActivityRedeemNotifications(User user, String content) {
notificationRepository.deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(admin, NotificationType.ACTIVITY_REDEEM, null, null,
null, user, null, content);
}
/**
* Create notifications for all admins when a user redeems a point good.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createPointRedeemNotifications(User user, String content) {
// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(
admin,
NotificationType.POINT_REDEEM,
null,
null,
null,
user,
null,
content
);
}
}
/**
* Create notifications for all admins when a user redeems a point good.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createPointRedeemNotifications(User user, String content) {
// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
null, user, null, content);
}
public List<NotificationPreferenceDto> listPreferences(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
List<NotificationPreferenceDto> prefs = new ArrayList<>();
for (NotificationType nt : NotificationType.values()) {
NotificationPreferenceDto dto = new NotificationPreferenceDto();
dto.setType(nt);
dto.setEnabled(!disabled.contains(nt));
prefs.add(dto);
}
return prefs;
}
public List<NotificationPreferenceDto> listPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
List<NotificationPreferenceDto> prefs = new ArrayList<>();
for (NotificationType nt : NotificationType.values()) {
NotificationPreferenceDto dto = new NotificationPreferenceDto();
dto.setType(nt);
dto.setEnabled(!disabled.contains(nt));
prefs.add(dto);
}
return prefs;
public void updatePreference(String username, NotificationType type, boolean enabled) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
userRepository.save(user);
}
public void updatePreference(String username, NotificationType type, boolean enabled) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
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 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 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) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(page, size);
org.springframework.data.domain.Page<Notification> result;
if (read == null) {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable);
} else {
result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(
user,
disabled,
pageable
);
}
} else {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable);
} else {
result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(
user,
read,
disabled,
pageable
);
}
}
return result.getContent();
}
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size);
org.springframework.data.domain.Page<Notification> result;
if (read == null) {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable);
} else {
result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable);
}
} else {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable);
} else {
result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable);
}
}
return result.getContent();
public void markRead(String username, List<Long> ids) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
List<Notification> notifs = notificationRepository.findAllById(ids);
for (Notification n : notifs) {
if (n.getUser().getId().equals(user.getId())) {
n.setRead(true);
}
}
notificationRepository.saveAll(notifs);
}
public void markRead(String username, List<Long> ids) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
List<Notification> notifs = notificationRepository.findAllById(ids);
for (Notification n : notifs) {
if (n.getUser().getId().equals(user.getId())) {
n.setRead(true);
}
}
notificationRepository.saveAll(notifs);
public long countUnread(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (disabled.isEmpty()) {
return notificationRepository.countByUserAndRead(user, false);
}
return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled);
}
public long countUnread(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (disabled.isEmpty()) {
return notificationRepository.countByUserAndRead(user, false);
}
return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled);
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {
if (content == null || fromUser == null) {
return;
}
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {
if (content == null || fromUser == null) {
return;
}
Matcher matcher = MENTION_PATTERN.matcher(content);
Set<String> names = new HashSet<>();
while (matcher.find()) {
names.add(matcher.group(1));
}
for (String name : names) {
userRepository.findByUsername(name).ifPresent(target -> {
if (!target.getId().equals(fromUser.getId())) {
createNotification(target, NotificationType.MENTION, post, comment, null, fromUser, null, null);
}
});
}
Matcher matcher = MENTION_PATTERN.matcher(content);
Set<String> names = new HashSet<>();
while (matcher.find()) {
names.add(matcher.group(1));
}
for (String name : names) {
userRepository
.findByUsername(name)
.ifPresent(target -> {
if (!target.getId().equals(fromUser.getId())) {
createNotification(
target,
NotificationType.MENTION,
post,
comment,
null,
fromUser,
null,
null
);
}
});
}
}
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import java.util.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@@ -9,57 +10,56 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.*;
@Service
public class OpenAiService {
@Value("${openai.api-key:}")
private String apiKey;
@Value("${openai.api-key:}")
private String apiKey;
@Value("${openai.model:gpt-4o}")
private String model;
@Value("${openai.model:gpt-4o}")
private String model;
private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate restTemplate = new RestTemplate();
public Optional<String> formatMarkdown(String text) {
if (apiKey == null || apiKey.isBlank()) {
return Optional.empty();
}
String url = "https://api.openai.com/v1/chat/completions";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
Map<String, Object> body = new HashMap<>();
body.put("model", model);
List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。"));
messages.add(Map.of("role", "user", "content", text));
body.put("messages", messages);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
Map respBody = resp.getBody();
if (respBody != null) {
Object choicesObj = respBody.get("choices");
if (choicesObj instanceof List choices && !choices.isEmpty()) {
Object first = choices.get(0);
if (first instanceof Map firstMap) {
Object messageObj = firstMap.get("message");
if (messageObj instanceof Map message) {
Object content = message.get("content");
if (content instanceof String str) {
return Optional.of(str.trim());
}
}
}
}
}
} catch (Exception ignored) {
}
return Optional.empty();
public Optional<String> formatMarkdown(String text) {
if (apiKey == null || apiKey.isBlank()) {
return Optional.empty();
}
String url = "https://api.openai.com/v1/chat/completions";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
Map<String, Object> body = new HashMap<>();
body.put("model", model);
List<Map<String, String>> messages = new ArrayList<>();
messages.add(
Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。")
);
messages.add(Map.of("role", "user", "content", text));
body.put("messages", messages);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
Map respBody = resp.getBody();
if (respBody != null) {
Object choicesObj = respBody.get("choices");
if (choicesObj instanceof List choices && !choices.isEmpty()) {
Object first = choices.get(0);
if (first instanceof Map firstMap) {
Object messageObj = firstMap.get("message");
if (messageObj instanceof Map message) {
Object content = message.get("content");
if (content instanceof String str) {
return Optional.of(str.trim());
}
}
}
}
}
} catch (Exception ignored) {}
return Optional.empty();
}
}

View File

@@ -1,73 +1,74 @@
package com.openisle.service;
import com.openisle.model.PasswordStrength;
import com.openisle.exception.FieldException;
import com.openisle.model.PasswordStrength;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class PasswordValidator {
private PasswordStrength strength;
public PasswordValidator(@Value("${app.password.strength:LOW}") PasswordStrength strength) {
this.strength = strength;
}
private PasswordStrength strength;
public PasswordStrength getStrength() {
return strength;
}
public PasswordValidator(@Value("${app.password.strength:LOW}") PasswordStrength strength) {
this.strength = strength;
}
public void setStrength(PasswordStrength strength) {
this.strength = strength;
}
public PasswordStrength getStrength() {
return strength;
}
public void validate(String password) {
if (password == null || password.isEmpty()) {
throw new FieldException("password", "Password cannot be empty");
}
switch (strength) {
case MEDIUM:
checkMedium(password);
break;
case HIGH:
checkHigh(password);
break;
default:
checkLow(password);
break;
}
}
public void setStrength(PasswordStrength strength) {
this.strength = strength;
}
private void checkLow(String password) {
if (password.length() < 6) {
throw new FieldException("password", "Password must be at least 6 characters long");
}
public void validate(String password) {
if (password == null || password.isEmpty()) {
throw new FieldException("password", "Password cannot be empty");
}
switch (strength) {
case MEDIUM:
checkMedium(password);
break;
case HIGH:
checkHigh(password);
break;
default:
checkLow(password);
break;
}
}
private void checkMedium(String password) {
if (password.length() < 8) {
throw new FieldException("password", "Password must be at least 8 characters long");
}
if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) {
throw new FieldException("password", "Password must contain letters and numbers");
}
private void checkLow(String password) {
if (password.length() < 6) {
throw new FieldException("password", "Password must be at least 6 characters long");
}
}
private void checkHigh(String password) {
if (password.length() < 12) {
throw new FieldException("password", "Password must be at least 12 characters long");
}
if (!password.matches(".*[A-Z].*")) {
throw new FieldException("password", "Password must contain uppercase letters");
}
if (!password.matches(".*[a-z].*")) {
throw new FieldException("password", "Password must contain lowercase letters");
}
if (!password.matches(".*\\d.*")) {
throw new FieldException("password", "Password must contain numbers");
}
if (!password.matches(".*[^A-Za-z0-9].*")) {
throw new FieldException("password", "Password must contain special characters");
}
private void checkMedium(String password) {
if (password.length() < 8) {
throw new FieldException("password", "Password must be at least 8 characters long");
}
if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) {
throw new FieldException("password", "Password must contain letters and numbers");
}
}
private void checkHigh(String password) {
if (password.length() < 12) {
throw new FieldException("password", "Password must be at least 12 characters long");
}
if (!password.matches(".*[A-Z].*")) {
throw new FieldException("password", "Password must contain uppercase letters");
}
if (!password.matches(".*[a-z].*")) {
throw new FieldException("password", "Password must contain lowercase letters");
}
if (!password.matches(".*\\d.*")) {
throw new FieldException("password", "Password must contain numbers");
}
if (!password.matches(".*[^A-Za-z0-9].*")) {
throw new FieldException("password", "Password must contain special characters");
}
}
}

View File

@@ -9,40 +9,41 @@ import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/** Service for point mall operations. */
@Service
@RequiredArgsConstructor
public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
}
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public int redeem(User user, Long goodId, String contact) {
PointGood good = pointGoodRepository.findById(goodId)
.orElseThrow(() -> new NotFoundException("Good not found"));
if (user.getPoint() < good.getCost()) {
throw new FieldException("point", "Insufficient points");
}
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(PointHistoryType.REDEEM);
history.setAmount(-good.getCost());
history.setBalance(user.getPoint());
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
return user.getPoint();
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
}
public int redeem(User user, Long goodId, String contact) {
PointGood good = pointGoodRepository
.findById(goodId)
.orElseThrow(() -> new NotFoundException("Good not found"));
if (user.getPoint() < good.getCost()) {
throw new FieldException("point", "Insufficient points");
}
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(PointHistoryType.REDEEM);
history.setAmount(-good.getCost());
history.setBalance(user.getPoint());
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
return user.getPoint();
}
}

View File

@@ -1,253 +1,275 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.model.*;
import com.openisle.repository.*;
import com.openisle.exception.FieldException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PointService {
private final UserRepository userRepository;
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
private final UserRepository userRepository;
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
}
public int awardForFeatured(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {
if (participant.getPoint() < cost) {
throw new FieldException("point", "积分不足");
}
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
}
}
private PointLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return pointLogRepository
.findByUserAndLogDate(user, today)
.orElseGet(() -> {
PointLog log = new PointLog();
log.setUser(user);
log.setLogDate(today);
log.setPostCount(0);
log.setCommentCount(0);
log.setReactionCount(0);
return pointLogRepository.save(log);
});
}
private int addPoint(
User user,
int amount,
PointHistoryType type,
Post post,
Comment comment,
User fromUser
) {
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount;
}
private void recordHistory(
User user,
PointHistoryType type,
int amount,
Post post,
Comment comment,
User fromUser
) {
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(type);
history.setAmount(amount);
history.setBalance(user.getPoint());
history.setPost(post);
history.setComment(comment);
history.setFromUser(fromUser);
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
}
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
public int awardForComment(String commenterName, Long postId, Long commentId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
Post post = postRepository.findById(postId).orElseThrow();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
PointLog log = getTodayLog(commenter);
if (log.getCommentCount() > 3) {
isTheRewardCapped = true;
}
// 如果发帖人与评论者是同一个,则只计算单次加分
if (poster.getId().equals(commenter.getId())) {
if (isTheRewardCapped) {
return 0;
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
} else {
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
}
}
// 需要考虑点赞者和发帖人是同一个的情况
public int awardForReactionOfPost(String reactionerName, Long postId) {
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果发帖人与点赞者是同一个,则不加分
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
// 如果不是同一个,则为发帖人加分
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
public int deductForReactionOfPost(String reactionerName, Long postId) {
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
public int awardForReactionOfComment(String reactionerName, Long commentId) {
// 根据帖子id找到评论者
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果评论者与点赞者是同一个,则不加分
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
public int awardForFeatured(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
// 如果不是同一个,则为发帖人加分
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public int deductForReactionOfComment(String reactionerName, Long commentId) {
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(
commenter,
-10,
PointHistoryType.COMMENT_LIKE_CANCELLED,
post,
comment,
reactioner
);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
public List<Map<String, Object>> trend(String userName, int days) {
if (days < 1) days = 1;
User user = userRepository.findByUsername(userName).orElseThrow();
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc(
user,
start.atStartOfDay()
);
int idx = 0;
int balance = user.getPoint();
List<Map<String, Object>> result = new ArrayList<>();
for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) {
result.add(Map.of("date", day.toString(), "value", balance));
while (
idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)
) {
balance -= histories.get(idx).getAmount();
idx++;
}
}
Collections.reverse(result);
return result;
}
/**
* 重新计算用户的积分总数
* 通过累加所有积分历史记录来重新计算用户的当前积分
*/
public int recalculateUserPoints(User user) {
// 获取用户所有的积分历史记录(由于@Where注解已删除的记录会被自动过滤
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdAsc(user);
int totalPoints = 0;
for (PointHistory history : histories) {
totalPoints += history.getAmount();
// 重新计算每条历史记录的余额
history.setBalance(totalPoints);
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {
if (participant.getPoint() < cost) {
throw new FieldException("point", "积分不足");
}
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
}
}
// 批量更新历史记录及用户积分
pointHistoryRepository.saveAll(histories);
user.setPoint(totalPoints);
userRepository.save(user);
private PointLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return pointLogRepository.findByUserAndLogDate(user, today)
.orElseGet(() -> {
PointLog log = new PointLog();
log.setUser(user);
log.setLogDate(today);
log.setPostCount(0);
log.setCommentCount(0);
log.setReactionCount(0);
return pointLogRepository.save(log);
});
}
private int addPoint(User user, int amount, PointHistoryType type,
Post post, Comment comment, User fromUser) {
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount;
}
private void recordHistory(User user, PointHistoryType type, int amount,
Post post, Comment comment, User fromUser) {
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(type);
history.setAmount(amount);
history.setBalance(user.getPoint());
history.setPost(post);
history.setComment(comment);
history.setFromUser(fromUser);
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
}
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
public int awardForComment(String commenterName, Long postId, Long commentId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
Post post = postRepository.findById(postId).orElseThrow();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
PointLog log = getTodayLog(commenter);
if (log.getCommentCount() > 3) {
isTheRewardCapped = true;
}
// 如果发帖人与评论者是同一个,则只计算单次加分
if (poster.getId().equals(commenter.getId())) {
if (isTheRewardCapped) {
return 0;
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
} else {
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
}
}
// 需要考虑点赞者和发帖人是同一个的情况
public int awardForReactionOfPost(String reactionerName, Long postId) {
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果发帖人与点赞者是同一个,则不加分
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
// 如果不是同一个,则为发帖人加分
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
public int deductForReactionOfPost(String reactionerName, Long postId) {
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
public int awardForReactionOfComment(String reactionerName, Long commentId) {
// 根据帖子id找到评论者
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果评论者与点赞者是同一个,则不加分
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
// 如果不是同一个,则为发帖人加分
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public int deductForReactionOfComment(String reactionerName, Long commentId) {
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
public List<Map<String, Object>> trend(String userName, int days) {
if (days < 1) days = 1;
User user = userRepository.findByUsername(userName).orElseThrow();
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc(
user, start.atStartOfDay());
int idx = 0;
int balance = user.getPoint();
List<Map<String, Object>> result = new ArrayList<>();
for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) {
result.add(Map.of("date", day.toString(), "value", balance));
while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) {
balance -= histories.get(idx).getAmount();
idx++;
}
}
Collections.reverse(result);
return result;
}
/**
* 重新计算用户的积分总数
* 通过累加所有积分历史记录来重新计算用户的当前积分
*/
public int recalculateUserPoints(User user) {
// 获取用户所有的积分历史记录(由于@Where注解已删除的记录会被自动过滤
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdAsc(user);
int totalPoints = 0;
for (PointHistory history : histories) {
totalPoints += history.getAmount();
// 重新计算每条历史记录的余额
history.setBalance(totalPoints);
}
// 批量更新历史记录及用户积分
pointHistoryRepository.saveAll(histories);
user.setPoint(totalPoints);
userRepository.save(user);
return totalPoints;
}
/**
* 重新计算用户的积分总数(通过用户名)
*/
public int recalculateUserPoints(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
return totalPoints;
}
/**
* 重新计算用户的积分总数(通过用户名)
*/
public int recalculateUserPoints(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
}

View File

@@ -4,118 +4,125 @@ import com.openisle.model.*;
import com.openisle.repository.PostChangeLogRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PostChangeLogService {
private final PostChangeLogRepository logRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private User getSystemUser() {
return userRepository.findByUsername("system")
.orElseThrow(() -> new IllegalStateException("System user not found"));
}
private final PostChangeLogRepository logRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
public void recordContentChange(Post post, User user, String oldContent, String newContent) {
PostContentChangeLog log = new PostContentChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CONTENT);
log.setOldContent(oldContent);
log.setNewContent(newContent);
logRepository.save(log);
}
private User getSystemUser() {
return userRepository
.findByUsername("system")
.orElseThrow(() -> new IllegalStateException("System user not found"));
}
public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) {
PostTitleChangeLog log = new PostTitleChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TITLE);
log.setOldTitle(oldTitle);
log.setNewTitle(newTitle);
logRepository.save(log);
}
public void recordContentChange(Post post, User user, String oldContent, String newContent) {
PostContentChangeLog log = new PostContentChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CONTENT);
log.setOldContent(oldContent);
log.setNewContent(newContent);
logRepository.save(log);
}
public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) {
PostCategoryChangeLog log = new PostCategoryChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CATEGORY);
log.setOldCategory(oldCategory);
log.setNewCategory(newCategory);
logRepository.save(log);
}
public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) {
PostTitleChangeLog log = new PostTitleChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TITLE);
log.setOldTitle(oldTitle);
log.setNewTitle(newTitle);
logRepository.save(log);
}
public void recordTagChange(Post post, User user, Set<Tag> oldTags, Set<Tag> newTags) {
PostTagChangeLog log = new PostTagChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TAG);
log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
logRepository.save(log);
}
public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) {
PostCategoryChangeLog log = new PostCategoryChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CATEGORY);
log.setOldCategory(oldCategory);
log.setNewCategory(newCategory);
logRepository.save(log);
}
public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) {
PostClosedChangeLog log = new PostClosedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CLOSED);
log.setOldClosed(oldClosed);
log.setNewClosed(newClosed);
logRepository.save(log);
}
public void recordTagChange(Post post, User user, Set<Tag> oldTags, Set<Tag> newTags) {
PostTagChangeLog log = new PostTagChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TAG);
log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
logRepository.save(log);
}
public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) {
PostPinnedChangeLog log = new PostPinnedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.PINNED);
log.setOldPinnedAt(oldPinnedAt);
log.setNewPinnedAt(newPinnedAt);
logRepository.save(log);
}
public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) {
PostClosedChangeLog log = new PostClosedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CLOSED);
log.setOldClosed(oldClosed);
log.setNewClosed(newClosed);
logRepository.save(log);
}
public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) {
PostFeaturedChangeLog log = new PostFeaturedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.FEATURED);
log.setOldFeatured(oldFeatured);
log.setNewFeatured(newFeatured);
logRepository.save(log);
}
public void recordPinnedChange(
Post post,
User user,
java.time.LocalDateTime oldPinnedAt,
java.time.LocalDateTime newPinnedAt
) {
PostPinnedChangeLog log = new PostPinnedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.PINNED);
log.setOldPinnedAt(oldPinnedAt);
log.setNewPinnedAt(newPinnedAt);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log);
}
public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) {
PostFeaturedChangeLog log = new PostFeaturedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.FEATURED);
log.setOldFeatured(oldFeatured);
log.setNewFeatured(newFeatured);
logRepository.save(log);
}
public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log);
}
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}
public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log);
}
public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return logRepository.findByPostOrderByCreatedAtAsc(post);
}
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}
public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return logRepository.findByPostOrderByCreatedAtAsc(post);
}
}

View File

@@ -6,44 +6,52 @@ import com.openisle.model.User;
import com.openisle.repository.PostReadRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class PostReadService {
private final PostReadRepository postReadRepository;
private final UserRepository userRepository;
private final PostRepository postRepository;
public void recordRead(String username, Long postId) {
if (username == null) return;
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
postReadRepository.findByUserAndPost(user, post).ifPresentOrElse(pr -> {
pr.setLastReadAt(LocalDateTime.now());
postReadRepository.save(pr);
}, () -> {
PostRead pr = new PostRead();
pr.setUser(user);
pr.setPost(post);
pr.setLastReadAt(LocalDateTime.now());
postReadRepository.save(pr);
});
}
private final PostReadRepository postReadRepository;
private final UserRepository userRepository;
private final PostRepository postRepository;
public long countReads(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return postReadRepository.countByUser(user);
}
public void recordRead(String username, Long postId) {
if (username == null) return;
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
postReadRepository
.findByUserAndPost(user, post)
.ifPresentOrElse(
pr -> {
pr.setLastReadAt(LocalDateTime.now());
postReadRepository.save(pr);
},
() -> {
PostRead pr = new PostRead();
pr.setUser(user);
pr.setPost(post);
pr.setLastReadAt(LocalDateTime.now());
postReadRepository.save(pr);
}
);
}
@org.springframework.transaction.annotation.Transactional
public void deleteByPost(Post post) {
postReadRepository.deleteByPost(post);
}
public long countReads(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return postReadRepository.countByUser(user);
}
@org.springframework.transaction.annotation.Transactional
public void deleteByPost(Post post) {
postReadRepository.deleteByPost(post);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@ package com.openisle.service;
import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import com.openisle.repository.PushSubscriptionRepository;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Security;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
@@ -11,42 +15,51 @@ 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;
@Slf4j
@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;
if (publicKey != null && !publicKey.isBlank() && privateKey != null && !privateKey.isBlank()) {
Security.addProvider(new BouncyCastleProvider());
this.pushService = new PushService(publicKey, privateKey);
} else {
this.pushService = null;
}
}
private final PushSubscriptionRepository subscriptionRepository;
private final PushService pushService;
public void sendNotification(User user, String payload) {
if (pushService == null) {
log.warn("Push notifications are disabled because VAPID keys are not configured.");
return;
}
List<PushSubscription> 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) {
log.error(e.getMessage());
}
}
public PushNotificationService(
PushSubscriptionRepository subscriptionRepository,
@Value("${app.webpush.public-key:}") String publicKey,
@Value("${app.webpush.private-key:}") String privateKey
) throws GeneralSecurityException {
this.subscriptionRepository = subscriptionRepository;
if (publicKey != null && !publicKey.isBlank() && privateKey != null && !privateKey.isBlank()) {
Security.addProvider(new BouncyCastleProvider());
this.pushService = new PushService(publicKey, privateKey);
} else {
this.pushService = null;
}
}
public void sendNotification(User user, String payload) {
if (pushService == null) {
log.warn("Push notifications are disabled because VAPID keys are not configured.");
return;
}
List<PushSubscription> 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
) {
log.error(e.getMessage());
}
}
}
}

View File

@@ -4,32 +4,33 @@ import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import com.openisle.repository.PushSubscriptionRepository;
import com.openisle.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
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"));
subscriptionRepository.deleteByUserAndEndpoint(user, endpoint);
PushSubscription sub = new PushSubscription();
sub.setUser(user);
sub.setEndpoint(endpoint);
sub.setP256dh(p256dh);
sub.setAuth(auth);
subscriptionRepository.save(sub);
}
private final PushSubscriptionRepository subscriptionRepository;
private final UserRepository userRepository;
public List<PushSubscription> listByUser(User user) {
return subscriptionRepository.findByUser(user);
}
@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"));
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<PushSubscription> listByUser(User user) {
return subscriptionRepository.findByUser(user);
}
}

View File

@@ -1,19 +1,19 @@
package com.openisle.service;
import com.openisle.model.Comment;
import com.openisle.model.Message;
import com.openisle.model.NotificationType;
import com.openisle.model.Post;
import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.model.NotificationType;
import com.openisle.model.Message;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.EmailSender;
import com.openisle.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -22,111 +22,153 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ReactionService {
private final ReactionRepository reactionRepository;
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService;
private final EmailSender emailSender;
@Value("${app.website-url}")
private String websiteUrl;
private final ReactionRepository reactionRepository;
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService;
private final EmailSender emailSender;
@Transactional
public Reaction reactToPost(String username, Long postId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndPostAndType(user, post, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, post, null, type);
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setPost(post);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type, null);
}
return reaction;
@Value("${app.website-url}")
private String websiteUrl;
@Transactional
public Reaction reactToPost(String username, Long postId, ReactionType type) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndPostAndType(
user,
post,
type
);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, post, null, type);
reactionRepository.delete(existing.get());
return null;
}
@Transactional
public Reaction reactToComment(String username, Long commentId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndCommentAndType(user, comment, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, null, comment, type);
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setComment(comment);
reaction.setPost(null);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(comment.getAuthor().getId())) {
notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type, null);
}
return reaction;
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setPost(post);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(
post.getAuthor(),
NotificationType.REACTION,
post,
null,
null,
user,
type,
null
);
}
return reaction;
}
@Transactional
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndMessageAndType(user, message, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
return reaction;
@Transactional
public Reaction reactToComment(String username, Long commentId, ReactionType type) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment comment = commentRepository
.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndCommentAndType(
user,
comment,
type
);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, null, comment, type);
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setComment(comment);
reaction.setPost(null);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
if (!user.getId().equals(comment.getAuthor().getId())) {
notificationService.createNotification(
comment.getAuthor(),
NotificationType.REACTION,
comment.getPost(),
comment,
null,
user,
type,
null
);
}
return reaction;
}
public java.util.List<Reaction> getReactionsForPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return reactionRepository.findByPost(post);
@Transactional
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Message message = messageRepository
.findById(messageId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndMessageAndType(
user,
message,
type
);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
return reaction;
}
public java.util.List<Reaction> getReactionsForComment(Long commentId) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
return reactionRepository.findByComment(comment);
}
public java.util.List<Reaction> getReactionsForPost(Long postId) {
Post post = postRepository
.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return reactionRepository.findByPost(post);
}
public java.util.List<Long> topPostIds(String username, int limit) {
return reactionRepository.findTopPostIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
}
public java.util.List<Reaction> getReactionsForComment(Long commentId) {
Comment comment = commentRepository
.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
return reactionRepository.findByComment(comment);
}
public java.util.List<Long> topCommentIds(String username, int limit) {
return reactionRepository.findTopCommentIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
}
public java.util.List<Long> topPostIds(String username, int limit) {
return reactionRepository.findTopPostIds(
username,
org.springframework.data.domain.PageRequest.of(0, limit)
);
}
public long countLikesSent(String username) {
return reactionRepository.countLikesSent(username);
}
public java.util.List<Long> topCommentIds(String username, int limit) {
return reactionRepository.findTopCommentIds(
username,
org.springframework.data.domain.PageRequest.of(0, limit)
);
}
public long countLikesReceived(String username) {
return reactionRepository.countLikesReceived(username);
}
public long countLikesSent(String username) {
return reactionRepository.countLikesSent(username);
}
public long countLikesReceived(String username) {
return reactionRepository.countLikesReceived(username);
}
}

View File

@@ -1,35 +1,35 @@
package com.openisle.service;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* CaptchaService implementation using Google reCAPTCHA.
*/
@Service
public class RecaptchaService extends CaptchaService {
@Value("${recaptcha.secret-key:}")
private String secretKey;
@Value("${recaptcha.secret-key:}")
private String secretKey;
private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate restTemplate = new RestTemplate();
@Override
public boolean verify(String token) {
if (token == null || token.isEmpty()) {
return false;
}
String url = "https://www.google.com/recaptcha/api/siteverify?secret={secret}&response={response}";
try {
ResponseEntity<Map> resp = restTemplate.postForEntity(url, null, Map.class, secretKey, token);
Map body = resp.getBody();
return body != null && Boolean.TRUE.equals(body.get("success"));
} catch (Exception e) {
return false;
}
@Override
public boolean verify(String token) {
if (token == null || token.isEmpty()) {
return false;
}
String url =
"https://www.google.com/recaptcha/api/siteverify?secret={secret}&response={response}";
try {
ResponseEntity<Map> resp = restTemplate.postForEntity(url, null, Map.class, secretKey, token);
Map body = resp.getBody();
return body != null && Boolean.TRUE.equals(body.get("success"));
} catch (Exception e) {
return false;
}
}
}

View File

@@ -9,17 +9,18 @@ import org.springframework.stereotype.Service;
*/
@Service
public class RegisterModeService {
private RegisterMode registerMode;
public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) {
this.registerMode = registerMode;
}
private RegisterMode registerMode;
public RegisterMode getRegisterMode() {
return registerMode;
}
public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) {
this.registerMode = registerMode;
}
public void setRegisterMode(RegisterMode mode) {
this.registerMode = mode;
}
public RegisterMode getRegisterMode() {
return registerMode;
}
public void setRegisterMode(RegisterMode mode) {
this.registerMode = mode;
}
}

View File

@@ -1,44 +1,43 @@
package com.openisle.service;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Service
public class ResendEmailSender extends EmailSender {
@Value("${resend.api.key}")
private String apiKey;
@Value("${resend.api.key}")
private String apiKey;
@Value("${resend.from.email}")
private String fromEmail;
@Value("${resend.from.email}")
private String fromEmail;
private final RestTemplate restTemplate = new RestTemplate();
private final RestTemplate restTemplate = new RestTemplate();
@Override
@Async("notificationExecutor")
public void sendEmail(String to, String subject, String text) {
String url = "https://api.resend.com/emails"; // hypothetical endpoint
@Override
@Async("notificationExecutor")
public void sendEmail(String to, String subject, String text) {
String url = "https://api.resend.com/emails"; // hypothetical endpoint
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
Map<String, String> body = new HashMap<>();
body.put("to", to);
body.put("subject", subject);
body.put("text", text);
body.put("from", "openisle <" + fromEmail + ">");
Map<String, String> body = new HashMap<>();
body.put("to", to);
body.put("subject", subject);
body.put("text", text);
body.put("from", "openisle <" + fromEmail + ">");
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
}
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
}
}

View File

@@ -1,171 +1,176 @@
package com.openisle.service;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.model.Comment;
import com.openisle.model.User;
import com.openisle.model.Category;
import com.openisle.model.Tag;
import com.openisle.repository.PostRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.UserRepository;
import com.openisle.model.User;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.stream.Stream;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class SearchService {
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
@org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}")
private int snippetLength;
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
public List<User> searchUsers(String keyword) {
return userRepository.findByUsernameContainingIgnoreCase(keyword);
@org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}")
private int snippetLength;
public List<User> searchUsers(String keyword) {
return userRepository.findByUsernameContainingIgnoreCase(keyword);
}
public List<Post> searchPosts(String keyword) {
return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(
keyword,
keyword,
PostStatus.PUBLISHED
);
}
public List<Post> searchPostsByContent(String keyword) {
return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Post> searchPostsByTitle(String keyword) {
return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Comment> searchComments(String keyword) {
return commentRepository.findByContentContainingIgnoreCase(keyword);
}
public List<Category> searchCategories(String keyword) {
return categoryRepository.findByNameContainingIgnoreCase(keyword);
}
public List<Tag> searchTags(String keyword) {
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public List<SearchResult> globalSearch(String keyword) {
Stream<SearchResult> users = searchUsers(keyword)
.stream()
.map(u ->
new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null)
);
Stream<SearchResult> categories = searchCategories(keyword)
.stream()
.map(c ->
new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null)
);
Stream<SearchResult> tags = searchTags(keyword)
.stream()
.map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null));
// Merge post results while removing duplicates between search by content
// and search by title
List<SearchResult> mergedPosts = Stream.concat(
searchPosts(keyword)
.stream()
.map(p ->
new SearchResult(
"post",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, false),
null
)
),
searchPostsByTitle(keyword)
.stream()
.map(p ->
new SearchResult(
"post_title",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, true),
null
)
)
)
.collect(
java.util.stream.Collectors.toMap(
SearchResult::id,
sr -> sr,
(a, b) -> a,
java.util.LinkedHashMap::new
)
)
.values()
.stream()
.toList();
Stream<SearchResult> comments = searchComments(keyword)
.stream()
.map(c ->
new SearchResult(
"comment",
c.getId(),
c.getPost().getTitle(),
c.getAuthor().getUsername(),
extractSnippet(c.getContent(), keyword, false),
c.getPost().getId()
)
);
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
.flatMap(s -> s)
.toList();
}
private String extractSnippet(String content, String keyword, boolean fromStart) {
if (content == null) return "";
int limit = snippetLength;
if (fromStart) {
if (limit < 0) {
return content;
}
return content.length() > limit ? content.substring(0, limit) : content;
}
public List<Post> searchPosts(String keyword) {
return postRepository
.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(keyword, keyword, PostStatus.PUBLISHED);
String lower = content.toLowerCase();
String kw = keyword.toLowerCase();
int idx = lower.indexOf(kw);
if (idx == -1) {
if (limit < 0) {
return content;
}
return content.length() > limit ? content.substring(0, limit) : content;
}
public List<Post> searchPostsByContent(String keyword) {
return postRepository
.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
int start = Math.max(0, idx - 20);
int end = Math.min(content.length(), idx + kw.length() + 20);
String snippet = content.substring(start, end);
if (limit >= 0 && snippet.length() > limit) {
snippet = snippet.substring(0, limit);
}
return snippet;
}
public List<Post> searchPostsByTitle(String keyword) {
return postRepository
.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Comment> searchComments(String keyword) {
return commentRepository.findByContentContainingIgnoreCase(keyword);
}
public List<Category> searchCategories(String keyword) {
return categoryRepository.findByNameContainingIgnoreCase(keyword);
}
public List<Tag> searchTags(String keyword) {
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public List<SearchResult> globalSearch(String keyword) {
Stream<SearchResult> users = searchUsers(keyword).stream()
.map(u -> new SearchResult(
"user",
u.getId(),
u.getUsername(),
u.getIntroduction(),
null,
null
));
Stream<SearchResult> categories = searchCategories(keyword).stream()
.map(c -> new SearchResult(
"category",
c.getId(),
c.getName(),
null,
c.getDescription(),
null
));
Stream<SearchResult> tags = searchTags(keyword).stream()
.map(t -> new SearchResult(
"tag",
t.getId(),
t.getName(),
null,
t.getDescription(),
null
));
// Merge post results while removing duplicates between search by content
// and search by title
List<SearchResult> mergedPosts = Stream.concat(
searchPosts(keyword).stream()
.map(p -> new SearchResult(
"post",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, false),
null
)),
searchPostsByTitle(keyword).stream()
.map(p -> new SearchResult(
"post_title",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, true),
null
))
)
.collect(java.util.stream.Collectors.toMap(
SearchResult::id,
sr -> sr,
(a, b) -> a,
java.util.LinkedHashMap::new
))
.values()
.stream()
.toList();
Stream<SearchResult> comments = searchComments(keyword).stream()
.map(c -> new SearchResult(
"comment",
c.getId(),
c.getPost().getTitle(),
c.getAuthor().getUsername(),
extractSnippet(c.getContent(), keyword, false),
c.getPost().getId()
));
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
.flatMap(s -> s)
.toList();
}
private String extractSnippet(String content, String keyword, boolean fromStart) {
if (content == null) return "";
int limit = snippetLength;
if (fromStart) {
if (limit < 0) {
return content;
}
return content.length() > limit ? content.substring(0, limit) : content;
}
String lower = content.toLowerCase();
String kw = keyword.toLowerCase();
int idx = lower.indexOf(kw);
if (idx == -1) {
if (limit < 0) {
return content;
}
return content.length() > limit ? content.substring(0, limit) : content;
}
int start = Math.max(0, idx - 20);
int end = Math.min(content.length(), idx + kw.length() + 20);
String snippet = content.substring(start, end);
if (limit >= 0 && snippet.length() > limit) {
snippet = snippet.substring(0, limit);
}
return snippet;
}
public record SearchResult(String type, Long id, String text, String subText, String extra, Long postId) {}
public record SearchResult(
String type,
Long id,
String text,
String subText,
String extra,
Long postId
) {}
}

View File

@@ -1,56 +1,68 @@
package com.openisle.service;
import com.openisle.repository.UserRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.CommentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class StatService {
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private Map<LocalDate, Long> toDateMap(LocalDate start, LocalDate end, java.util.List<Object[]> list) {
Map<LocalDate, Long> result = new LinkedHashMap<>();
for (var obj : list) {
Object dateObj = obj[0];
LocalDate d;
if (dateObj instanceof java.sql.Date sqlDate) {
d = sqlDate.toLocalDate();
} else if (dateObj instanceof LocalDate localDate) {
d = localDate;
} else {
d = LocalDate.parse(dateObj.toString());
}
Long c = ((Number) obj[1]).longValue();
result.put(d, c);
}
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
result.putIfAbsent(d, 0L);
}
return result;
}
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
public Map<LocalDate, Long> countNewUsersRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = userRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay());
return toDateMap(start, end, list);
private Map<LocalDate, Long> toDateMap(
LocalDate start,
LocalDate end,
java.util.List<Object[]> list
) {
Map<LocalDate, Long> result = new LinkedHashMap<>();
for (var obj : list) {
Object dateObj = obj[0];
LocalDate d;
if (dateObj instanceof java.sql.Date sqlDate) {
d = sqlDate.toLocalDate();
} else if (dateObj instanceof LocalDate localDate) {
d = localDate;
} else {
d = LocalDate.parse(dateObj.toString());
}
Long c = ((Number) obj[1]).longValue();
result.put(d, c);
}
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
result.putIfAbsent(d, 0L);
}
return result;
}
public Map<LocalDate, Long> countPostsRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = postRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay());
return toDateMap(start, end, list);
}
public Map<LocalDate, Long> countNewUsersRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = userRepository.countDailyRange(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay()
);
return toDateMap(start, end, list);
}
public Map<LocalDate, Long> countCommentsRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = commentRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay());
return toDateMap(start, end, list);
}
public Map<LocalDate, Long> countPostsRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = postRepository.countDailyRange(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay()
);
return toDateMap(start, end, list);
}
public Map<LocalDate, Long> countCommentsRange(LocalDate start, LocalDate end) {
java.util.List<Object[]> list = commentRepository.countDailyRange(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay()
);
return toDateMap(start, end, list);
}
}

View File

@@ -2,153 +2,194 @@ package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SubscriptionService {
private final PostSubscriptionRepository postSubRepo;
private final CommentSubscriptionRepository commentSubRepo;
private final UserSubscriptionRepository userSubRepo;
private final UserRepository userRepo;
private final PostRepository postRepo;
private final CommentRepository commentRepo;
private final NotificationService notificationService;
public void subscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo.findByUserAndPost(user, post).orElseGet(() -> {
PostSubscription ps = new PostSubscription();
ps.setUser(user);
ps.setPost(post);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(),
NotificationType.POST_SUBSCRIBED, post, null, null, user, null, null);
}
return postSubRepo.save(ps);
});
}
private final PostSubscriptionRepository postSubRepo;
private final CommentSubscriptionRepository commentSubRepo;
private final UserSubscriptionRepository userSubRepo;
private final UserRepository userRepo;
private final PostRepository postRepo;
private final CommentRepository commentRepo;
private final NotificationService notificationService;
public void unsubscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo.findByUserAndPost(user, post).ifPresent(ps -> {
postSubRepo.delete(ps);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(),
NotificationType.POST_UNSUBSCRIBED, post, null, null, user, null, null);
}
});
}
public void subscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo.findByUserAndComment(user, comment).orElseGet(() -> {
CommentSubscription cs = new CommentSubscription();
cs.setUser(user);
cs.setComment(comment);
return commentSubRepo.save(cs);
});
}
public void unsubscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete);
}
public void subscribeUser(String username, String targetName) {
if (username.equals(targetName)) return;
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = findUser(targetName).orElseThrow();
userSubRepo.findBySubscriberAndTarget(subscriber, target).orElseGet(() -> {
UserSubscription us = new UserSubscription();
us.setSubscriber(subscriber);
us.setTarget(target);
notificationService.createNotification(target,
NotificationType.USER_FOLLOWED, null, null, null, subscriber, null, null);
return userSubRepo.save(us);
});
}
public void unsubscribeUser(String username, String targetName) {
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = findUser(targetName).orElseThrow();
userSubRepo.findBySubscriberAndTarget(subscriber, target).ifPresent(us -> {
userSubRepo.delete(us);
notificationService.createNotification(target,
NotificationType.USER_UNFOLLOWED, null, null, null, subscriber, null, null);
});
}
public List<User> getSubscribedUsers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList();
}
public List<User> getSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList();
}
public List<User> getPostSubscribers(Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList();
}
public List<User> getCommentSubscribers(Long commentId) {
Comment c = commentRepo.findById(commentId).orElseThrow();
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
}
public List<Post> getSubscribedPosts(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
}
public long countSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countByTarget(user);
}
public long countSubscribed(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countBySubscriber(user);
}
public boolean isSubscribed(String subscriberName, String targetName) {
if (subscriberName == null || targetName == null || subscriberName.equals(targetName)) {
return false;
public void subscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo
.findByUserAndPost(user, post)
.orElseGet(() -> {
PostSubscription ps = new PostSubscription();
ps.setUser(user);
ps.setPost(post);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(
post.getAuthor(),
NotificationType.POST_SUBSCRIBED,
post,
null,
null,
user,
null,
null
);
}
Optional<User> subscriber = userRepo.findByUsername(subscriberName);
Optional<User> target = findUser(targetName);
if (subscriber.isEmpty() || target.isEmpty()) {
// 修改个人信息会出现,先不抛出错误
return false;
}
return userSubRepo.findBySubscriberAndTarget(subscriber.get(), target.get()).isPresent();
}
return postSubRepo.save(ps);
});
}
public boolean isPostSubscribed(String username, Long postId) {
if (username == null || postId == null) {
return false;
public void unsubscribePost(String username, Long postId) {
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
postSubRepo
.findByUserAndPost(user, post)
.ifPresent(ps -> {
postSubRepo.delete(ps);
if (!user.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(
post.getAuthor(),
NotificationType.POST_UNSUBSCRIBED,
post,
null,
null,
user,
null,
null
);
}
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
return postSubRepo.findByUserAndPost(user, post).isPresent();
}
});
}
private Optional<User> findUser(String identifier) {
if (identifier.matches("\\d+")) {
return userRepo.findById(Long.parseLong(identifier));
}
return userRepo.findByUsername(identifier);
public void subscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo
.findByUserAndComment(user, comment)
.orElseGet(() -> {
CommentSubscription cs = new CommentSubscription();
cs.setUser(user);
cs.setComment(comment);
return commentSubRepo.save(cs);
});
}
public void unsubscribeComment(String username, Long commentId) {
User user = userRepo.findByUsername(username).orElseThrow();
Comment comment = commentRepo.findById(commentId).orElseThrow();
commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete);
}
public void subscribeUser(String username, String targetName) {
if (username.equals(targetName)) return;
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = findUser(targetName).orElseThrow();
userSubRepo
.findBySubscriberAndTarget(subscriber, target)
.orElseGet(() -> {
UserSubscription us = new UserSubscription();
us.setSubscriber(subscriber);
us.setTarget(target);
notificationService.createNotification(
target,
NotificationType.USER_FOLLOWED,
null,
null,
null,
subscriber,
null,
null
);
return userSubRepo.save(us);
});
}
public void unsubscribeUser(String username, String targetName) {
User subscriber = userRepo.findByUsername(username).orElseThrow();
User target = findUser(targetName).orElseThrow();
userSubRepo
.findBySubscriberAndTarget(subscriber, target)
.ifPresent(us -> {
userSubRepo.delete(us);
notificationService.createNotification(
target,
NotificationType.USER_UNFOLLOWED,
null,
null,
null,
subscriber,
null,
null
);
});
}
public List<User> getSubscribedUsers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList();
}
public List<User> getSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList();
}
public List<User> getPostSubscribers(Long postId) {
Post post = postRepo.findById(postId).orElseThrow();
return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList();
}
public List<User> getCommentSubscribers(Long commentId) {
Comment c = commentRepo.findById(commentId).orElseThrow();
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
}
public List<Post> getSubscribedPosts(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
}
public long countSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countByTarget(user);
}
public long countSubscribed(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return userSubRepo.countBySubscriber(user);
}
public boolean isSubscribed(String subscriberName, String targetName) {
if (subscriberName == null || targetName == null || subscriberName.equals(targetName)) {
return false;
}
Optional<User> subscriber = userRepo.findByUsername(subscriberName);
Optional<User> target = findUser(targetName);
if (subscriber.isEmpty() || target.isEmpty()) {
// 修改个人信息会出现,先不抛出错误
return false;
}
return userSubRepo.findBySubscriberAndTarget(subscriber.get(), target.get()).isPresent();
}
public boolean isPostSubscribed(String username, Long postId) {
if (username == null || postId == null) {
return false;
}
User user = userRepo.findByUsername(username).orElseThrow();
Post post = postRepo.findById(postId).orElseThrow();
return postSubRepo.findByUserAndPost(user, post).isPresent();
}
private Optional<User> findUser(String identifier) {
if (identifier.matches("\\d+")) {
return userRepo.findById(Long.parseLong(identifier));
}
return userRepo.findByUsername(identifier);
}
}

View File

@@ -5,133 +5,152 @@ import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TagService {
private final TagRepository tagRepository;
private final TagValidator tagValidator;
private final UserRepository userRepository;
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
tagValidator.validate(name);
Tag tag = new Tag();
tag.setName(name);
tag.setDescription(description);
tag.setIcon(icon);
tag.setSmallIcon(smallIcon);
tag.setApproved(approved);
if (creatorUsername != null) {
User creator = userRepository.findByUsername(creatorUsername)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
tag.setCreator(creator);
}
return tagRepository.save(tag);
private final TagRepository tagRepository;
private final TagValidator tagValidator;
private final UserRepository userRepository;
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag createTag(
String name,
String description,
String icon,
String smallIcon,
boolean approved,
String creatorUsername
) {
tagValidator.validate(name);
Tag tag = new Tag();
tag.setName(name);
tag.setDescription(description);
tag.setIcon(icon);
tag.setSmallIcon(smallIcon);
tag.setApproved(approved);
if (creatorUsername != null) {
User creator = userRepository
.findByUsername(creatorUsername)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
tag.setCreator(creator);
}
return tagRepository.save(tag);
}
public Tag createTag(
String name,
String description,
String icon,
String smallIcon,
boolean approved
) {
return createTag(name, description, icon, smallIcon, approved, null);
}
public Tag createTag(String name, String description, String icon, String smallIcon) {
return createTag(name, description, icon, smallIcon, true, null);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
Tag tag = tagRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
if (name != null) {
tagValidator.validate(name);
tag.setName(name);
}
if (description != null) {
tag.setDescription(description);
}
if (icon != null) {
tag.setIcon(icon);
}
if (smallIcon != null) {
tag.setSmallIcon(smallIcon);
}
return tagRepository.save(tag);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public void deleteTag(Long id) {
tagRepository.deleteById(id);
}
public Tag approveTag(Long id) {
Tag tag = tagRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
tag.setApproved(true);
return tagRepository.save(tag);
}
public List<Tag> listPendingTags() {
return tagRepository.findByApproved(false);
}
public Tag getTag(Long id) {
return tagRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
}
public List<Tag> listTags() {
return tagRepository.findByApprovedTrue();
}
/**
* 该方法每次首页加载都会访问,加入缓存
* @param keyword
* @return
*/
@Cacheable(
value = CachingConfig.TAG_CACHE_NAME,
key = "'searchTags:' + (#keyword ?: '')" //keyword为null的场合返回空
)
public List<Tag> searchTags(String keyword) {
if (keyword == null || keyword.isBlank()) {
return tagRepository.findByApprovedTrue();
}
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved) {
return createTag(name, description, icon, smallIcon, approved, null);
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public Tag createTag(String name, String description, String icon, String smallIcon) {
return createTag(name, description, icon, smallIcon, true, null);
}
public List<Tag> getRecentTagsByUser(String username, int limit) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
return tagRepository.findByCreatorOrderByCreatedAtDesc(user, pageable);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
Tag tag = tagRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
if (name != null) {
tagValidator.validate(name);
tag.setName(name);
}
if (description != null) {
tag.setDescription(description);
}
if (icon != null) {
tag.setIcon(icon);
}
if (smallIcon != null) {
tag.setSmallIcon(smallIcon);
}
return tagRepository.save(tag);
}
public List<Tag> getTagsByUser(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return tagRepository.findByCreator(user);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public void deleteTag(Long id) {
tagRepository.deleteById(id);
}
public Tag approveTag(Long id) {
Tag tag = tagRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
tag.setApproved(true);
return tagRepository.save(tag);
}
public List<Tag> listPendingTags() {
return tagRepository.findByApproved(false);
}
public Tag getTag(Long id) {
return tagRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
}
public List<Tag> listTags() {
return tagRepository.findByApprovedTrue();
}
/**
* 该方法每次首页加载都会访问,加入缓存
* @param keyword
* @return
*/
@Cacheable(
value = CachingConfig.TAG_CACHE_NAME,
key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空
)
public List<Tag> searchTags(String keyword) {
if (keyword == null || keyword.isBlank()) {
return tagRepository.findByApprovedTrue();
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public List<Tag> getRecentTagsByUser(String username, int limit) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
return tagRepository.findByCreatorOrderByCreatedAtDesc(user, pageable);
}
public List<Tag> getTagsByUser(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return tagRepository.findByCreator(user);
}
/**
* 获取检索用的标签Id列表
* @param tagIds
* @param tagId
* @return
*/
public List<Long> getSearchTagIds(List<Long> tagIds, Long tagId){
List<Long> ids = tagIds;
if (tagId != null) {
ids = List.of(tagId);
}
return ids;
/**
* 获取检索用的标签Id列表
* @param tagIds
* @param tagId
* @return
*/
public List<Long> getSearchTagIds(List<Long> tagIds, Long tagId) {
List<Long> ids = tagIds;
if (tagId != null) {
ids = List.of(tagId);
}
return ids;
}
}

View File

@@ -1,20 +1,20 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@Service
public class TagValidator {
private static final Pattern ALLOWED = Pattern.compile("^[A-Za-z0-9\\u4e00-\\u9fa5]+$");
public void validate(String name) {
if (name == null || name.isBlank()) {
throw new FieldException("name", "Tag name cannot be empty");
}
if (!ALLOWED.matcher(name).matches()) {
throw new FieldException("name", "Tag name must be letters or numbers");
}
private static final Pattern ALLOWED = Pattern.compile("^[A-Za-z0-9\\u4e00-\\u9fa5]+$");
public void validate(String name) {
if (name == null || name.isBlank()) {
throw new FieldException("name", "Tag name cannot be empty");
}
if (!ALLOWED.matcher(name).matches()) {
throw new FieldException("name", "Tag name must be letters or numbers");
}
}
}

View File

@@ -5,98 +5,108 @@ import com.openisle.model.RegisterMode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
@Service
@RequiredArgsConstructor
public class TelegramAuthService {
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
@Value("${telegram.bot-token:}")
private String botToken;
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
try {
if (botToken == null || botToken.isEmpty()) {
return Optional.empty();
}
String dataCheckString = buildDataCheckString(req);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
String hex = bytesToHex(hash);
if (!hex.equalsIgnoreCase(req.getHash())) {
return Optional.empty();
}
String username = req.getUsername();
String email = (username != null ? username : req.getId()) + "@telegram.org";
String avatar = req.getPhotoUrl();
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
@Value("${telegram.bot-token:}")
private String botToken;
public Optional<AuthResult> authenticate(
TelegramLoginRequest req,
RegisterMode mode,
boolean viaInvite
) {
try {
if (botToken == null || botToken.isEmpty()) {
return Optional.empty();
}
String dataCheckString = buildDataCheckString(req);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
String hex = bytesToHex(hash);
if (!hex.equalsIgnoreCase(req.getHash())) {
return Optional.empty();
}
String username = req.getUsername();
String email = (username != null ? username : req.getId()) + "@telegram.org";
String avatar = req.getPhotoUrl();
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private String buildDataCheckString(TelegramLoginRequest req) {
List<String> data = new ArrayList<>();
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
if (req.getId() != null) data.add("id=" + req.getId());
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
if (req.getUsername() != null) data.add("username=" + req.getUsername());
Collections.sort(data);
return String.join("\n", data);
private String buildDataCheckString(TelegramLoginRequest req) {
List<String> data = new ArrayList<>();
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
if (req.getId() != null) data.add("id=" + req.getId());
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
if (req.getUsername() != null) data.add("username=" + req.getUsername());
Collections.sort(data);
return String.join("\n", data);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
private AuthResult processUser(
String email,
String username,
String avatar,
RegisterMode mode,
boolean viaInvite
) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -5,142 +5,157 @@ import com.openisle.model.RegisterMode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Base64;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.util.MultiValueMap;
import org.springframework.util.LinkedMultiValueMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.*;
@Service
@RequiredArgsConstructor
public class TwitterAuthService {
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
private static final Logger logger = LoggerFactory.getLogger(TwitterAuthService.class);
@Value("${twitter.client-id:}")
private String clientId;
private final UserRepository userRepository;
private final RestTemplate restTemplate = new RestTemplate();
private static final Logger logger = LoggerFactory.getLogger(TwitterAuthService.class);
@Value("${twitter.client-secret:}")
private String clientSecret;
@Value("${twitter.client-id:}")
private String clientId;
public Optional<AuthResult> authenticate(
String code,
String codeVerifier,
RegisterMode mode,
String redirectUri,
boolean viaInvite) {
@Value("${twitter.client-secret:}")
private String clientSecret;
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
public Optional<AuthResult> authenticate(
String code,
String codeVerifier,
RegisterMode mode,
String redirectUri,
boolean viaInvite
) {
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
// 1. 交换 token
String tokenUrl = "https://api.twitter.com/2/oauth2/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (!clientId.isEmpty() && !clientSecret.isEmpty()) {
String credentials = clientId + ":" + clientSecret;
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
}
// Twitter PKCE 要求的五个参数
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("code_verifier", codeVerifier);
body.add("redirect_uri", redirectUri); // 一律必填
// 如果你的 app 属于机密客户端,必须带 client_secret
body.add("client_secret", clientSecret);
ResponseEntity<JsonNode> tokenRes;
try {
logger.debug("Requesting token from {}", tokenUrl);
tokenRes = restTemplate.postForEntity(tokenUrl, new HttpEntity<>(body, headers), JsonNode.class);
logger.debug("Token response: {}", tokenRes.getBody());
} catch (HttpClientErrorException e) {
logger.warn("Token request failed with status {} and body {}", e.getStatusCode(), e.getResponseBodyAsString());
return Optional.empty();
}
JsonNode tokenJson = tokenRes.getBody();
if (tokenJson == null || !tokenJson.hasNonNull("access_token")) {
return Optional.empty();
}
String accessToken = tokenJson.get("access_token").asText();
// 2. 拉取用户信息
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
ResponseEntity<JsonNode> userRes;
try {
logger.debug("Fetching user info with access token");
userRes = restTemplate.exchange(
"https://api.twitter.com/2/users/me?user.fields=profile_image_url",
HttpMethod.GET,
new HttpEntity<>(authHeaders),
JsonNode.class);
logger.debug("User info response: {}", userRes.getBody());
} catch (HttpClientErrorException e) {
logger.debug("User info request failed", e);
return Optional.empty();
}
JsonNode data = userRes.getBody() == null ? null : userRes.getBody().path("data");
String username = data != null ? data.path("username").asText(null) : null;
String avatar = data != null ? data.path("profile_image_url").asText(null) : null;
if (username == null) {
return Optional.empty();
}
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
// 1. 交换 token
String tokenUrl = "https://api.twitter.com/2/oauth2/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (!clientId.isEmpty() && !clientSecret.isEmpty()) {
String credentials = clientId + ":" + clientSecret;
String authHeader =
"Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
logger.debug("Existing user {} authenticated", user.getUsername());
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
// Twitter PKCE 要求的五个参数
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("code_verifier", codeVerifier);
body.add("redirect_uri", redirectUri); // 一律必填
// 如果你的 app 属于机密客户端,必须带 client_secret
body.add("client_secret", clientSecret);
ResponseEntity<JsonNode> tokenRes;
try {
logger.debug("Requesting token from {}", tokenUrl);
tokenRes = restTemplate.postForEntity(
tokenUrl,
new HttpEntity<>(body, headers),
JsonNode.class
);
logger.debug("Token response: {}", tokenRes.getBody());
} catch (HttpClientErrorException e) {
logger.warn(
"Token request failed with status {} and body {}",
e.getStatusCode(),
e.getResponseBodyAsString()
);
return Optional.empty();
}
JsonNode tokenJson = tokenRes.getBody();
if (tokenJson == null || !tokenJson.hasNonNull("access_token")) {
return Optional.empty();
}
String accessToken = tokenJson.get("access_token").asText();
// 2. 拉取用户信息
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(accessToken);
ResponseEntity<JsonNode> userRes;
try {
logger.debug("Fetching user info with access token");
userRes = restTemplate.exchange(
"https://api.twitter.com/2/users/me?user.fields=profile_image_url",
HttpMethod.GET,
new HttpEntity<>(authHeaders),
JsonNode.class
);
logger.debug("User info response: {}", userRes.getBody());
} catch (HttpClientErrorException e) {
logger.debug("User info request failed", e);
return Optional.empty();
}
JsonNode data = userRes.getBody() == null ? null : userRes.getBody().path("data");
String username = data != null ? data.path("username").asText(null) : null;
String avatar = data != null ? data.path("profile_image_url").asText(null) : null;
if (username == null) {
return Optional.empty();
}
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
}
private AuthResult processUser(
String email,
String username,
String avatar,
com.openisle.model.RegisterMode mode,
boolean viaInvite
) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
}
logger.debug("Creating new user {}", finalUsername);
return new AuthResult(userRepository.save(user), true);
user.setVerificationCode(null);
userRepository.save(user);
}
logger.debug("Existing user {} authenticated", user.getUsername());
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
}
logger.debug("Creating new user {}", finalUsername);
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -1,14 +1,18 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.exception.FieldException;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.AvatarGenerator;
import com.openisle.service.PasswordValidator;
import com.openisle.service.UsernameValidator;
import com.openisle.service.AvatarGenerator;
import com.openisle.exception.FieldException;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
@@ -16,219 +20,230 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordValidator passwordValidator;
private final UsernameValidator usernameValidator;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator;
private final RedisTemplate redisTemplate;
private final UserRepository userRepository;
private final PasswordValidator passwordValidator;
private final UsernameValidator usernameValidator;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator;
private final EmailSender emailService;
private final RedisTemplate redisTemplate;
public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username);
passwordValidator.validate(password);
// ── 先按用户名查 ──────────────────────────────────────────
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isPresent()) {
User u = byUsername.get();
if (u.isVerified()) { // 已验证 → 直接拒绝
throw new FieldException("username", "User name already exists");
}
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password));
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
}
private final EmailSender emailService;
// ── 再按邮箱查 ───────────────────────────────────────────
Optional<User> byEmail = userRepository.findByEmail(email);
if (byEmail.isPresent()) {
User u = byEmail.get();
if (u.isVerified()) { // 已验证 → 直接拒绝
throw new FieldException("email", "User email already exists");
}
// 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password));
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
}
// ── 完全新用户 ───────────────────────────────────────────
User user = new User();
user.setUsername(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER);
user.setVerified(false);
// user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(user);
public User register(
String username,
String email,
String password,
String reason,
com.openisle.model.RegisterMode mode
) {
usernameValidator.validate(username);
passwordValidator.validate(password);
// ── 先按用户名查 ──────────────────────────────────────────
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isPresent()) {
User u = byUsername.get();
if (u.isVerified()) {
// 已验证 → 直接拒绝
throw new FieldException("username", "User name already exists");
}
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password));
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
}
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
// user.setVerificationCode(genCode());
return userRepository.save(user);
// ── 再按邮箱查 ───────────────────────────────────────────
Optional<User> byEmail = userRepository.findByEmail(email);
if (byEmail.isPresent()) {
User u = byEmail.get();
if (u.isVerified()) {
// 已验证 → 直接拒绝
throw new FieldException("email", "User email already exists");
}
// 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password));
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
}
private String genCode() {
return String.format("%06d", new Random().nextInt(1000000));
// ── 完全新用户 ───────────────────────────────────────────
User user = new User();
user.setUsername(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER);
user.setVerified(false);
// user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(user);
}
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
// user.setVerificationCode(genCode());
return userRepository.save(user);
}
private String genCode() {
return String.format("%06d", new Random().nextInt(1000000));
}
/**
* 将验证码存入缓存,并发送邮件
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType) {
// 缓存验证码
String code = genCode();
String key;
String subject;
String content = "您的验证码是:" + code;
// 注册类型
if (verifyType.equals(VerifyType.REGISTER)) {
key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername();
subject = "在网站填写验证码以验证(有效期为5分钟)";
} else {
// 重置密码
key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername();
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
/**
* 将验证码存入缓存,并发送邮件
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType){
// 缓存验证码
String code = genCode();
String key;
String subject;
String content = "您的验证码是:" + code;
// 注册类型
if(verifyType.equals(VerifyType.REGISTER)){
key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername();
subject = "在网站填写验证码以验证(有效期为5分钟)";
}else {
// 重置密码
key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername();
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
}
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);// 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
/**
* 验证code是否正确
* @param user
* @param code
* @param verifyType
* @return
*/
public boolean verifyCode(User user, String code, VerifyType verifyType) {
// 生成key
String key1 = VerifyType.REGISTER.equals(verifyType)
? ":register:code:"
: ":reset_password:code:";
String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername();
// 这里不能使用getAndDelete,需要6.x版本
String cachedCode = (String) redisTemplate.opsForValue().get(key);
// 如果校验code过期或者不存在
// 或者校验code不一致
if (Objects.isNull(cachedCode) || !cachedCode.equals(code)) {
return false;
}
/**
* 验证code是否正确
* @param user
* @param code
* @param verifyType
* @return
*/
public boolean verifyCode(User user, String code, VerifyType verifyType) {
// 生成key
String key1 = VerifyType.REGISTER.equals(verifyType)?":register:code:":":reset_password:code:";
String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername();
// 这里不能使用getAndDelete,需要6.x版本
String cachedCode = (String)redisTemplate.opsForValue().get(key);
// 如果校验code过期或者不存在
// 或者校验code不一致
if(Objects.isNull(cachedCode)
|| !cachedCode.equals(code)){
return false;
}
// 注册模式需要设置已经确认
if(VerifyType.REGISTER.equals(verifyType)){
user.setVerified(true);
userRepository.save(user);
}
// 走到这里说明验证成功删除验证码
redisTemplate.delete(key);
return true;
// 注册模式需要设置已经确认
if (VerifyType.REGISTER.equals(verifyType)) {
user.setVerified(true);
userRepository.save(user);
}
// 走到这里说明验证成功删除验证码
redisTemplate.delete(key);
return true;
}
public Optional<User> authenticate(String username, String password) {
return userRepository.findByUsername(username)
.filter(User::isVerified)
.filter(User::isApproved)
.filter(user -> passwordEncoder.matches(password, user.getPassword()));
}
public Optional<User> authenticate(String username, String password) {
return userRepository
.findByUsername(username)
.filter(User::isVerified)
.filter(User::isApproved)
.filter(user -> passwordEncoder.matches(password, user.getPassword()));
}
public boolean matchesPassword(User user, String rawPassword) {
return passwordEncoder.matches(rawPassword, user.getPassword());
}
public boolean matchesPassword(User user, String rawPassword) {
return passwordEncoder.matches(rawPassword, user.getPassword());
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public Optional<User> findByIdentifier(String identifier) {
if (identifier.matches("\\d+")) {
return userRepository.findById(Long.parseLong(identifier));
}
return userRepository.findByUsername(identifier);
public Optional<User> findByIdentifier(String identifier) {
if (identifier.matches("\\d+")) {
return userRepository.findById(Long.parseLong(identifier));
}
return userRepository.findByUsername(identifier);
}
public User updateAvatar(String username, String avatarUrl) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String old = user.getAvatar();
user.setAvatar(avatarUrl);
User saved = userRepository.save(user);
if (old != null && !old.equals(avatarUrl)) {
imageUploader.removeReferences(java.util.Set.of(old));
}
if (avatarUrl != null) {
imageUploader.addReferences(java.util.Set.of(avatarUrl));
}
return saved;
public User updateAvatar(String username, String avatarUrl) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String old = user.getAvatar();
user.setAvatar(avatarUrl);
User saved = userRepository.save(user);
if (old != null && !old.equals(avatarUrl)) {
imageUploader.removeReferences(java.util.Set.of(old));
}
if (avatarUrl != null) {
imageUploader.addReferences(java.util.Set.of(avatarUrl));
}
return saved;
}
public User updateReason(String username, String reason) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
user.setRegisterReason(reason);
return userRepository.save(user);
}
public User updateReason(String username, String reason) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
user.setRegisterReason(reason);
return userRepository.save(user);
}
public User updateProfile(String currentUsername, String newUsername, String introduction) {
User user = userRepository.findByUsername(currentUsername)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (newUsername != null && !newUsername.equals(currentUsername)) {
usernameValidator.validate(newUsername);
userRepository.findByUsername(newUsername).ifPresent(u -> {
throw new FieldException("username", "User name already exists");
});
user.setUsername(newUsername);
}
if (introduction != null) {
user.setIntroduction(introduction);
}
return userRepository.save(user);
public User updateProfile(String currentUsername, String newUsername, String introduction) {
User user = userRepository
.findByUsername(currentUsername)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (newUsername != null && !newUsername.equals(currentUsername)) {
usernameValidator.validate(newUsername);
userRepository
.findByUsername(newUsername)
.ifPresent(u -> {
throw new FieldException("username", "User name already exists");
});
user.setUsername(newUsername);
}
if (introduction != null) {
user.setIntroduction(introduction);
}
return userRepository.save(user);
}
public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword);
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
/**
* Get all administrator accounts.
*/
public java.util.List<User> getAdmins() {
return userRepository.findByRole(Role.ADMIN);
}
/**
* Get all administrator accounts.
*/
public java.util.List<User> getAdmins() {
return userRepository.findByRole(Role.ADMIN);
}
}

View File

@@ -5,92 +5,97 @@ import com.openisle.model.User;
import com.openisle.model.UserVisit;
import com.openisle.repository.UserRepository;
import com.openisle.repository.UserVisitRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
@Service
@RequiredArgsConstructor
public class UserVisitService {
private final UserVisitRepository userVisitRepository;
private final UserRepository userRepository;
private final RedisTemplate redisTemplate;
private final UserVisitRepository userVisitRepository;
private final UserRepository userRepository;
public boolean recordVisit(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
LocalDate today = LocalDate.now();
return userVisitRepository.findByUserAndVisitDate(user, today).map(v -> false).orElseGet(() -> {
UserVisit visit = new UserVisit();
visit.setUser(user);
visit.setVisitDate(today);
userVisitRepository.save(visit);
return true;
});
private final RedisTemplate redisTemplate;
public boolean recordVisit(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
LocalDate today = LocalDate.now();
return userVisitRepository
.findByUserAndVisitDate(user, today)
.map(v -> false)
.orElseGet(() -> {
UserVisit visit = new UserVisit();
visit.setUser(user);
visit.setVisitDate(today);
userVisitRepository.save(visit);
return true;
});
}
/**
* 统计访问次数,改为从缓存获取/数据库获取
* @param username
* @return
*/
public long countVisits(String username) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
// 如果缓存存在就返回
String key1 = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now() + ":count:" + username;
Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if (cached != null) {
return cached.longValue();
}
/**
* 统计访问次数,改为从缓存获取/数据库获取
* @param username
* @return
*/
public long countVisits(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
// Redis Set 检查今天是否访问
String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username);
// 如果缓存存在就返回
String key1 = CachingConfig.VISIT_CACHE_NAME + ":" +LocalDate.now() + ":count:" + username;
Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if (cached != null){
return cached.longValue();
}
Long visitCount = userVisitRepository.countByUser(user);
if (todayVisited) visitCount += 1;
// Redis Set 检查今天是否访问
String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username);
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds();
Long visitCount = userVisitRepository.countByUser(user);
if (todayVisited) visitCount += 1;
// 写入缓存,设置 TTL当天剩余时间
redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay));
return visitCount;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds();
public long countDau(LocalDate date) {
LocalDate d = date != null ? date : LocalDate.now();
return userVisitRepository.countByVisitDate(d);
}
// 写入缓存,设置 TTL当天剩余时间
redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay));
return visitCount;
public Map<LocalDate, Long> countDauRange(LocalDate start, LocalDate end) {
Map<LocalDate, Long> result = new LinkedHashMap<>();
if (start == null || end == null || start.isAfter(end)) {
return result;
}
public long countDau(LocalDate date) {
LocalDate d = date != null ? date : LocalDate.now();
return userVisitRepository.countByVisitDate(d);
var list = userVisitRepository.countRange(start, end);
for (var obj : list) {
LocalDate d = (LocalDate) obj[0];
Long c = (Long) obj[1];
result.put(d, c);
}
public Map<LocalDate, Long> countDauRange(LocalDate start, LocalDate end) {
Map<LocalDate, Long> result = new LinkedHashMap<>();
if (start == null || end == null || start.isAfter(end)) {
return result;
}
var list = userVisitRepository.countRange(start, end);
for (var obj : list) {
LocalDate d = (LocalDate) obj[0];
Long c = (Long) obj[1];
result.put(d, c);
}
// fill zero counts for missing dates
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
result.putIfAbsent(d, 0L);
}
return result;
// fill zero counts for missing dates
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
result.putIfAbsent(d, 0L);
}
return result;
}
}

View File

@@ -9,20 +9,19 @@ import org.springframework.stereotype.Service;
*/
@Service
public class UsernameValidator {
/**
* Validate the username string.
*
* @param username the username to validate
*/
public void validate(String username) {
if (username == null || username.isEmpty()) {
throw new FieldException("username", "Username cannot be empty");
}
if (NumberUtils.isDigits(username)) {
throw new FieldException("username", "Username cannot be pure number");
}
/**
* Validate the username string.
*
* @param username the username to validate
*/
public void validate(String username) {
if (username == null || username.isEmpty()) {
throw new FieldException("username", "Username cannot be empty");
}
if (NumberUtils.isDigits(username)) {
throw new FieldException("username", "Username cannot be pure number");
}
}
}