mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-03 18:40:46 +08:00
优化目录结构
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.ActivityRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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();
|
||||
}
|
||||
|
||||
public Activity getByType(ActivityType type) {
|
||||
Activity a = activityRepository.findByType(type);
|
||||
if (a == null) throw new NotFoundException("Activity not found");
|
||||
return a;
|
||||
}
|
||||
|
||||
public long countLevel1Users() {
|
||||
int threshold = levelService.nextLevelExp(0);
|
||||
return userRepository.countByExperienceGreaterThanEqual(threshold);
|
||||
}
|
||||
|
||||
public void end(Activity activity) {
|
||||
activity.setEnded(true);
|
||||
activityRepository.save(activity);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.AiFormatUsage;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.AiFormatUsageRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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;
|
||||
|
||||
public int getFormatLimit() {
|
||||
return formatLimit;
|
||||
}
|
||||
|
||||
public void setFormatLimit(int formatLimit) {
|
||||
this.formatLimit = 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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class AvatarGenerator {
|
||||
|
||||
@Value("${app.avatar.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${app.avatar.style}")
|
||||
private String style;
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public Category getCategory(Long id) {
|
||||
return categoryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
}
|
||||
|
||||
public List<Category> listCategories() {
|
||||
return categoryRepository.findAll();
|
||||
}
|
||||
}
|
||||
185
backend/src/main/java/com/openisle/service/CommentService.java
Normal file
185
backend/src/main/java/com/openisle/service/CommentService.java
Normal file
@@ -0,0 +1,185 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.CommentSort;
|
||||
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.service.NotificationService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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 ImageUploader imageUploader;
|
||||
|
||||
public Comment addComment(String username, Long postId, String content) {
|
||||
long recent = commentRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(1));
|
||||
if (recent >= 3) {
|
||||
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"));
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(post);
|
||||
comment.setContent(content);
|
||||
comment = commentRepository.save(comment);
|
||||
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);
|
||||
return comment;
|
||||
}
|
||||
|
||||
public Comment addReply(String username, Long parentId, String content) {
|
||||
long recent = commentRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(1));
|
||||
if (recent >= 3) {
|
||||
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"));
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(parent.getPost());
|
||||
comment.setParent(parent);
|
||||
comment.setContent(content);
|
||||
comment = commentRepository.save(comment);
|
||||
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);
|
||||
return comment;
|
||||
}
|
||||
|
||||
public List<Comment> getCommentsForPost(Long postId, CommentSort sort) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
||||
if (sort == CommentSort.NEWEST) {
|
||||
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
||||
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<Comment> getReplies(Long parentId) {
|
||||
Comment parent = commentRepository.findById(parentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
return commentRepository.findByParentOrderByCreatedAtAsc(parent);
|
||||
}
|
||||
|
||||
public List<Comment> getRecentCommentsByUser(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 commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable);
|
||||
}
|
||||
|
||||
public java.util.List<User> getParticipants(Long postId, int 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);
|
||||
return list.subList(0, Math.min(limit, list.size()));
|
||||
}
|
||||
|
||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||
return commentRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastCommentTime(Long postId) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
return commentRepository.findLastCommentTime(post);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deleteComment(String username, Long 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) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
deleteCommentCascade(comment);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deleteCommentCascade(Comment comment) {
|
||||
List<Comment> replies = commentRepository.findByParentOrderByCreatedAtAsc(comment);
|
||||
for (Comment c : replies) {
|
||||
deleteCommentCascade(c);
|
||||
}
|
||||
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||
commentRepository.delete(comment);
|
||||
}
|
||||
|
||||
private int interactionCount(Comment comment) {
|
||||
int reactions = reactionRepository.findByComment(comment).size();
|
||||
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
||||
return reactions + replies;
|
||||
}
|
||||
}
|
||||
124
backend/src/main/java/com/openisle/service/CosImageUploader.java
Normal file
124
backend/src/main/java/com/openisle/service/CosImageUploader.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* ImageUploader implementation using Tencent Cloud COS.
|
||||
*/
|
||||
@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-"));
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.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;
|
||||
|
||||
@Value("${discord.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
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));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
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 user;
|
||||
}
|
||||
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);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
76
backend/src/main/java/com/openisle/service/DraftService.java
Normal file
76
backend/src/main/java/com/openisle/service/DraftService.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Draft;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
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;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
14
backend/src/main/java/com/openisle/service/EmailSender.java
Normal file
14
backend/src/main/java/com/openisle/service/EmailSender.java
Normal file
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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;
|
||||
|
||||
@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;
|
||||
|
||||
@Value("${github.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
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 {
|
||||
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));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
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 user;
|
||||
}
|
||||
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);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||
import com.google.api.client.json.jackson2.JacksonFactory;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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;
|
||||
|
||||
@Value("${google.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
||||
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));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
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 user;
|
||||
}
|
||||
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);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
106
backend/src/main/java/com/openisle/service/ImageUploader.java
Normal file
106
backend/src/main/java/com/openisle/service/ImageUploader.java
Normal file
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
import java.util.regex.Matcher;
|
||||
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);
|
||||
} 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 {
|
||||
img.setRefCount(count);
|
||||
imageRepository.save(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
99
backend/src/main/java/com/openisle/service/JwtService.java
Normal file
99
backend/src/main/java/com/openisle/service/JwtService.java
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
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.reset-secret}")
|
||||
private String resetSecret;
|
||||
|
||||
@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 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 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 validateAndGetSubjectForReset(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(resetSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
90
backend/src/main/java/com/openisle/service/LevelService.java
Normal file
90
backend/src/main/java/com/openisle/service/LevelService.java
Normal file
@@ -0,0 +1,90 @@
|
||||
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 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 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;
|
||||
}
|
||||
|
||||
public int nextLevelExp(int exp) {
|
||||
for (int t : LEVEL_EXP) {
|
||||
if (exp < t) return t;
|
||||
}
|
||||
return LEVEL_EXP[LEVEL_EXP.length-1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.openisle.service.EmailSender;
|
||||
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 java.util.Map;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
|
||||
import java.util.List;
|
||||
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 static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
private String buildPayload(String body, String url) {
|
||||
// try {
|
||||
// return new ObjectMapper().writeValueAsString(Map.of(
|
||||
// "body", body,
|
||||
// "url", url
|
||||
// ));
|
||||
// } catch (Exception e) {
|
||||
// return body;
|
||||
// }
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public void sendCustomPush(User user, String body, String url) {
|
||||
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);
|
||||
n = notificationRepository.save(n);
|
||||
|
||||
notificationExecutor.execute(() -> {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
|
||||
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||
String pushContent = comment.getAuthor() + "回复了你: \"" + comment.getContent() + "\"";
|
||||
emailSender.sendEmail(user.getEmail(), "您有新的回复", pushContent + ", 点击以查看: " + url);
|
||||
sendCustomPush(user, pushContent, url);
|
||||
} else if (type == NotificationType.REACTION && comment != null) {
|
||||
long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
||||
if (count % 5 == 0) {
|
||||
String url = websiteUrl + "/messages";
|
||||
sendCustomPush(comment.getAuthor(), "你有新的互动", url);
|
||||
if (comment.getAuthor().getEmail() != null) {
|
||||
emailSender.sendEmail(comment.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Notification> listNotifications(String username, Boolean read) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (read == null) {
|
||||
return notificationRepository.findByUserOrderByCreatedAtDesc(user);
|
||||
}
|
||||
return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read);
|
||||
}
|
||||
|
||||
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"));
|
||||
return notificationRepository.countByUserAndRead(user, false);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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.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.model:gpt-4o}")
|
||||
private String model;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PasswordStrength;
|
||||
import com.openisle.exception.FieldException;
|
||||
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;
|
||||
}
|
||||
|
||||
public PasswordStrength getStrength() {
|
||||
return strength;
|
||||
}
|
||||
|
||||
public void setStrength(PasswordStrength strength) {
|
||||
this.strength = 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;
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLow(String password) {
|
||||
if (password.length() < 6) {
|
||||
throw new FieldException("password", "Password must be at least 6 characters long");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PostReadRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
486
backend/src/main/java/com/openisle/service/PostService.java
Normal file
486
backend/src/main/java/com/openisle/service/PostService.java
Normal file
@@ -0,0 +1,486 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PublishMode;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.PostSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PostService {
|
||||
private final PostRepository postRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private PublishMode publishMode;
|
||||
private final NotificationService notificationService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final CommentService commentService;
|
||||
private final CommentRepository commentRepository;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final PostSubscriptionRepository postSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final PostReadService postReadService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public PostService(PostRepository postRepository,
|
||||
UserRepository userRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
TagRepository tagRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
CommentService commentService,
|
||||
CommentRepository commentRepository,
|
||||
ReactionRepository reactionRepository,
|
||||
PostSubscriptionRepository postSubscriptionRepository,
|
||||
NotificationRepository notificationRepository,
|
||||
PostReadService postReadService,
|
||||
ImageUploader imageUploader,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.subscriptionService = subscriptionService;
|
||||
this.commentService = commentService;
|
||||
this.commentRepository = commentRepository;
|
||||
this.reactionRepository = reactionRepository;
|
||||
this.postSubscriptionRepository = postSubscriptionRepository;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.postReadService = postReadService;
|
||||
this.imageUploader = imageUploader;
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
public PublishMode getPublishMode() {
|
||||
return publishMode;
|
||||
}
|
||||
|
||||
public void setPublishMode(PublishMode publishMode) {
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
public Post createPost(String username,
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(5));
|
||||
if (recent >= 1) {
|
||||
throw new RateLimitException("Too many posts");
|
||||
}
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
}
|
||||
if (tagIds.size() > 2) {
|
||||
throw new IllegalArgumentException("At most two tags allowed");
|
||||
}
|
||||
User author = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Category category = categoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
throw new IllegalArgumentException("Tag not found");
|
||||
}
|
||||
Post post = new Post();
|
||||
post.setTitle(title);
|
||||
post.setContent(content);
|
||||
post.setAuthor(author);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (post.getStatus() == PostStatus.PENDING) {
|
||||
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
|
||||
for (User admin : admins) {
|
||||
notificationService.createNotification(admin,
|
||||
NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null, null);
|
||||
}
|
||||
notificationService.createNotification(author,
|
||||
NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null, null);
|
||||
}
|
||||
// notify followers of author
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(
|
||||
u,
|
||||
NotificationType.FOLLOWED_POST,
|
||||
post,
|
||||
null,
|
||||
null,
|
||||
author,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Post viewPost(Long id, String viewer) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
}
|
||||
User viewerUser = userRepository.findByUsername(viewer)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!viewerUser.getRole().equals(com.openisle.model.Role.ADMIN) && !viewerUser.getId().equals(post.getAuthor().getId())) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
}
|
||||
}
|
||||
post.setViews(post.getViews() + 1);
|
||||
post = postRepository.save(post);
|
||||
if (viewer != null) {
|
||||
postReadService.recordRead(viewer, id);
|
||||
}
|
||||
if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) {
|
||||
User viewerUser = userRepository.findByUsername(viewer).orElse(null);
|
||||
if (viewerUser != null) {
|
||||
notificationRepository.deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewerUser, post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null, null);
|
||||
}
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
public List<Post> listPosts() {
|
||||
return listPostsByCategories(null, null, null);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||
return listPostsByViews(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
if (hasTags) {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
||||
categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED);
|
||||
}
|
||||
} else {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
}
|
||||
|
||||
return paginate(sortByPinnedAndViews(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||||
return listPostsByLatestReply(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
if (hasTags) {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
||||
categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
|
||||
}
|
||||
} else {
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
}
|
||||
|
||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategories(java.util.List<Long> categoryIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (categoryIds == null || categoryIds.isEmpty()) {
|
||||
java.util.List<Post> posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
java.util.List<Post> posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> getRecentPostsByUser(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 postRepository.findByAuthorAndStatusOrderByCreatedAtDesc(user, PostStatus.PUBLISHED, pageable);
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastPostTime(String username) {
|
||||
return postRepository.findLastPostTime(username);
|
||||
}
|
||||
|
||||
public long getTotalViews(String username) {
|
||||
Long v = postRepository.sumViews(username);
|
||||
return v != null ? v : 0;
|
||||
}
|
||||
|
||||
public List<Post> listPostsByTags(java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Post> posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategoriesAndTags(java.util.List<Long> categoryIds,
|
||||
java.util.List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (categories.isEmpty() || tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<Post> posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size());
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listPendingPosts() {
|
||||
return postRepository.findByStatus(PostStatus.PENDING);
|
||||
}
|
||||
|
||||
public Post approvePost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
// publish all pending tags along with the post
|
||||
for (com.openisle.model.Tag tag : post.getTags()) {
|
||||
if (!tag.isApproved()) {
|
||||
tag.setApproved(true);
|
||||
tagRepository.save(tag);
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true, null, null, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
public Post rejectPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
// remove user created tags that are only linked to this post
|
||||
java.util.Set<com.openisle.model.Tag> tags = new java.util.HashSet<>(post.getTags());
|
||||
for (com.openisle.model.Tag tag : tags) {
|
||||
if (!tag.isApproved()) {
|
||||
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
||||
if (count <= 1) {
|
||||
post.getTags().remove(tag);
|
||||
tagRepository.delete(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.REJECTED);
|
||||
post = postRepository.save(post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false, null, null, null);
|
||||
return post;
|
||||
}
|
||||
|
||||
public Post pinPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setPinnedAt(java.time.LocalDateTime.now());
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post unpinPost(Long id) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setPinnedAt(null);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public Post updatePost(Long id,
|
||||
String username,
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
}
|
||||
if (tagIds.size() > 2) {
|
||||
throw new IllegalArgumentException("At most two tags allowed");
|
||||
}
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
Category category = categoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||||
if (tags.isEmpty()) {
|
||||
throw new IllegalArgumentException("Tag not found");
|
||||
}
|
||||
post.setTitle(title);
|
||||
String oldContent = post.getContent();
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void deletePost(Long id, String username) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
|
||||
commentService.deleteCommentCascade(c);
|
||||
}
|
||||
reactionRepository.findByPost(post).forEach(reactionRepository::delete);
|
||||
postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByPost(post));
|
||||
postReadService.deleteByPost(post);
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(post.getContent()));
|
||||
postRepository.delete(post);
|
||||
}
|
||||
|
||||
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {
|
||||
return postRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public long countPostsByCategory(Long categoryId) {
|
||||
return postRepository.countByCategory_Id(categoryId);
|
||||
}
|
||||
|
||||
public long countPostsByTag(Long tagId) {
|
||||
return postRepository.countDistinctByTags_Id(tagId);
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndViews(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(Post::getViews, java.util.Comparator.reverseOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndLastReply(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
|
||||
.thenComparing(p -> {
|
||||
java.time.LocalDateTime t = commentRepository.findLastCommentTime(p);
|
||||
return t != null ? t : p.getCreatedAt();
|
||||
}, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private java.util.List<Post> paginate(java.util.List<Post> posts, Integer page, Integer pageSize) {
|
||||
if (page == null || pageSize == null) {
|
||||
return posts;
|
||||
}
|
||||
int from = page * pageSize;
|
||||
if (from >= posts.size()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
int to = Math.min(from + pageSize, posts.size());
|
||||
return posts.subList(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PushSubscription;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PushSubscriptionRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import nl.martijndwars.webpush.Notification;
|
||||
import nl.martijndwars.webpush.PushService;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
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;
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
this.pushService = new PushService(publicKey, privateKey);
|
||||
}
|
||||
|
||||
public void sendNotification(User user, String payload) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PushSubscription;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PushSubscriptionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import 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);
|
||||
}
|
||||
|
||||
public List<PushSubscription> listByUser(User user) {
|
||||
return subscriptionRepository.findByUser(user);
|
||||
}
|
||||
}
|
||||
104
backend/src/main/java/com/openisle/service/ReactionService.java
Normal file
104
backend/src/main/java/com/openisle/service/ReactionService.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
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.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReactionService {
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final EmailSender emailSender;
|
||||
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
|
||||
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()) {
|
||||
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);
|
||||
}
|
||||
|
||||
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> topPostIds(String username, int limit) {
|
||||
return reactionRepository.findTopPostIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
|
||||
}
|
||||
|
||||
public java.util.List<Long> topCommentIds(String username, int limit) {
|
||||
return reactionRepository.findTopCommentIds(username, org.springframework.data.domain.PageRequest.of(0, limit));
|
||||
}
|
||||
|
||||
public long countLikesSent(String username) {
|
||||
return reactionRepository.countLikesSent(username);
|
||||
}
|
||||
|
||||
public long countLikesReceived(String username) {
|
||||
return reactionRepository.countLikesReceived(username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.RegisterMode;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Holds current registration mode. Configurable at runtime.
|
||||
*/
|
||||
@Service
|
||||
public class RegisterModeService {
|
||||
private RegisterMode registerMode;
|
||||
|
||||
public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) {
|
||||
this.registerMode = registerMode;
|
||||
}
|
||||
|
||||
public RegisterMode getRegisterMode() {
|
||||
return registerMode;
|
||||
}
|
||||
|
||||
public void setRegisterMode(RegisterMode mode) {
|
||||
this.registerMode = mode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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.web.client.RestTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ResendEmailSender extends EmailSender {
|
||||
|
||||
@Value("${resend.api.key}")
|
||||
private String apiKey;
|
||||
|
||||
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
|
||||
|
||||
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 <noreply@chenjiating.com>"); // todo(tim): use config
|
||||
|
||||
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
|
||||
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
||||
}
|
||||
}
|
||||
171
backend/src/main/java/com/openisle/service/SearchService.java
Normal file
171
backend/src/main/java/com/openisle/service/SearchService.java
Normal file
@@ -0,0 +1,171 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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.repository.CategoryRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
|
||||
@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);
|
||||
});
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
107
backend/src/main/java/com/openisle/service/TagService.java
Normal file
107
backend/src/main/java/com/openisle/service/TagService.java
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
20
backend/src/main/java/com/openisle/service/TagValidator.java
Normal file
20
backend/src/main/java/com/openisle/service/TagValidator.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.FieldException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
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;
|
||||
|
||||
@Value("${twitter.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<User> authenticate(
|
||||
String code,
|
||||
String codeVerifier,
|
||||
RegisterMode mode,
|
||||
String redirectUri) {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
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 user;
|
||||
}
|
||||
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);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||
}
|
||||
logger.debug("Creating new user {}", finalUsername);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
195
backend/src/main/java/com/openisle/service/UserService.java
Normal file
195
backend/src/main/java/com/openisle/service/UserService.java
Normal file
@@ -0,0 +1,195 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Role;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
|
||||
@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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 再按邮箱查 ───────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
|
||||
private String genCode() {
|
||||
return String.format("%06d", new Random().nextInt(1000000));
|
||||
}
|
||||
|
||||
public boolean verifyCode(String username, String code) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(username);
|
||||
if (userOpt.isPresent() && code.equals(userOpt.get().getVerificationCode())) {
|
||||
User user = userOpt.get();
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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 Optional<User> findByUsername(String username) {
|
||||
return userRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return userRepository.findByEmail(email);
|
||||
}
|
||||
|
||||
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 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 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 String generatePasswordResetCode(String email) {
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
String code = genCode();
|
||||
user.setPasswordResetCode(code);
|
||||
userRepository.save(user);
|
||||
return code;
|
||||
}
|
||||
|
||||
public boolean verifyPasswordResetCode(String email, String code) {
|
||||
Optional<User> userOpt = userRepository.findByEmail(email);
|
||||
if (userOpt.isPresent() && code.equals(userOpt.get().getPasswordResetCode())) {
|
||||
User user = userOpt.get();
|
||||
user.setPasswordResetCode(null);
|
||||
userRepository.save(user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserVisitService {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
public long countVisits(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
return userVisitRepository.countByUser(user);
|
||||
}
|
||||
|
||||
public long countDau(LocalDate date) {
|
||||
LocalDate d = date != null ? date : LocalDate.now();
|
||||
return userVisitRepository.countByVisitDate(d);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.FieldException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Simple validator for usernames.
|
||||
*/
|
||||
@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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user