优化目录结构

This commit is contained in:
WilliamColton
2025-08-03 01:27:28 +08:00
parent d63081955e
commit c08723574d
222 changed files with 2 additions and 25 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View 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;
}
}

View 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
);
}
}

View File

@@ -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);
}
}

View 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);
})
);
}
}

View 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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
});
}
}

View 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();
}
}

View 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];
}
}

View File

@@ -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);
}
});
}
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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());
}
}
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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) {}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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");
}
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}