Compare commits

...

19 Commits

Author SHA1 Message Date
Tim
b36b5b59dc Fix PostServiceTest constructor parameters 2025-09-17 14:23:27 +08:00
Tim
cf96806f80 Merge pull request #979 from sivdead/optimize-post-list-n+1
主页列表接口优化,优化帖子评论统计性能
2025-09-17 14:17:31 +08:00
Tim
3d0d0496b6 fix: comment count 放在last_reply_at后更新,确保数据正确 2025-09-17 14:16:49 +08:00
Tim
f67e220894 fix: 旧帖子的last_reply_at也要及时更新(仅一次) 2025-09-17 14:14:55 +08:00
Tim
9306e35b84 Merge remote-tracking branch 'origin/main' into pr-979 2025-09-17 13:49:34 +08:00
Tim
d2268a1944 Merge pull request #971 from smallclover/main
缓存功能追加
2025-09-17 13:43:40 +08:00
Tim
6baa4d4233 fix: 简单调整按钮格式 2025-09-17 13:37:52 +08:00
Tim
ef9d90455f Merge pull request #991 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-mrgsx4
Delete post change logs before removing posts
2025-09-17 13:31:31 +08:00
Tim
5d499956d7 Delete post change logs before removing posts 2025-09-17 13:30:58 +08:00
Tim
9101ed336c Merge pull request #990 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-1xt4ec
Fix foreign key failures when deleting posts
2025-09-17 12:29:25 +08:00
Tim
28e3ebb911 Handle point history cleanup when deleting posts 2025-09-17 12:29:09 +08:00
Tim
e93e33fe43 Revert "Handle point history cleanup when deleting posts"
This reverts commit b4a811ff4e.
2025-09-17 12:27:07 +08:00
Tim
0ebeccf21e Merge branch 'pr-971' of github.com:nagisa77/OpenIsle into pr-971 2025-09-17 12:23:40 +08:00
Tim
89842b82e9 fix: 文章缓存修改为 10 min 2025-09-17 12:23:20 +08:00
Tim
58594229f2 Merge pull request #989 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost
Handle point history cleanup when deleting posts
2025-09-17 12:21:34 +08:00
Tim
b4a811ff4e Handle point history cleanup when deleting posts 2025-09-17 12:21:17 +08:00
Tim
7067630bcc fix: 验证码部分验证完毕,提交小修改 2025-09-17 12:06:02 +08:00
Tim
b28e8d4bc9 Merge pull request #988 from nagisa77/codex/update-post_cache_name-to-handle-pagination
Fix post cache keys to include pagination parameters
2025-09-17 11:53:05 +08:00
sivdead
1a21ba8935 feat(posts): 优化帖子评论统计性能
- 在 Post 模型中添加 commentCount 和 lastReplyAt 字段
- 在 CommentService 中实现更新帖子评论统计的方法
- 在 PostMapper 中使用 Post 模型中的评论统计字段
- 新增数据库迁移脚本,添加评论统计字段和索引
- 更新相关测试用例
2025-09-12 11:08:59 +08:00
16 changed files with 319 additions and 19 deletions

View File

@@ -97,8 +97,10 @@ public class CachingConfig {
// 个别缓存单独设置 TTL 时间 // 个别缓存单独设置 TTL 时间
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>(); Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig); cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
return RedisCacheManager.builder(connectionFactory) return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config) .cacheDefaults(config)

View File

@@ -67,7 +67,6 @@ public class PostMapper {
dto.setCategory(categoryMapper.toDto(post.getCategory())); dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList())); dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews()); dto.setViews(post.getViews());
dto.setCommentCount(commentService.countComments(post.getId()));
dto.setStatus(post.getStatus()); dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt()); dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded()); dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
@@ -82,8 +81,12 @@ public class PostMapper {
List<User> participants = commentService.getParticipants(post.getId(), 5); List<User> participants = commentService.getParticipants(post.getId(), 5);
dto.setParticipants(participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); dto.setParticipants(participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
LocalDateTime last = commentService.getLastCommentTime(post.getId()); LocalDateTime last = post.getLastReplyAt();
dto.setLastReplyAt(last != null ? last : post.getCreatedAt()); if (last == null) {
commentService.updatePostCommentStats(post);
}
dto.setCommentCount(post.getCommentCount());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setReward(0); dto.setReward(0);
dto.setSubscribed(false); dto.setSubscribed(false);
dto.setType(post.getType()); dto.setType(post.getType());

View File

@@ -72,4 +72,10 @@ public class Post {
@Column(nullable = true) @Column(nullable = true)
private Boolean rssExcluded = true; private Boolean rssExcluded = true;
@Column(nullable = false)
private long commentCount = 0;
@Column(nullable = true)
private LocalDateTime lastReplyAt;
} }

View File

@@ -1,8 +1,9 @@
package com.openisle.repository; package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.Post;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -14,6 +15,8 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
long countByUser(User user); long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment); List<PointHistory> findByComment(Comment comment);
List<PointHistory> findByPost(Post post);
} }

View File

@@ -8,4 +8,6 @@ import java.util.List;
public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> { public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> {
List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post); List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post);
void deleteByPost(Post post);
} }

View File

@@ -76,6 +76,10 @@ public class CommentService {
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
log.debug("Comment {} saved for post {}", comment.getId(), postId); log.debug("Comment {} saved for post {}", comment.getId(), postId);
// Update post comment statistics
updatePostCommentStats(post);
imageUploader.addReferences(imageUploader.extractUrls(content)); imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) { if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
@@ -129,6 +133,10 @@ public class CommentService {
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
log.debug("Reply {} saved for parent {}", comment.getId(), parentId); log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
// Update post comment statistics
updatePostCommentStats(parent.getPost());
imageUploader.addReferences(imageUploader.extractUrls(content)); imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) { if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
@@ -282,9 +290,13 @@ public class CommentService {
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论 // 逻辑删除评论
Post post = comment.getPost();
commentRepository.delete(comment); commentRepository.delete(comment);
// 删除积分历史 // 删除积分历史
pointHistoryRepository.deleteAll(pointHistories); pointHistoryRepository.deleteAll(pointHistories);
// Update post comment statistics
updatePostCommentStats(post);
// 重新计算受影响用户的积分 // 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) { if (!usersToRecalculate.isEmpty()) {
@@ -330,4 +342,23 @@ public class CommentService {
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
return reactions + replies; return reactions + replies;
} }
/**
* Update post comment statistics (comment count and last reply time)
*/
public void updatePostCommentStats(Post post) {
long commentCount = commentRepository.countByPostId(post.getId());
post.setCommentCount(commentCount);
LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post);
if (lastReplyAt == null) {
post.setLastReplyAt(post.getCreatedAt());
} else {
post.setLastReplyAt(lastReplyAt);
}
postRepository.save(post);
log.debug("Updated post {} stats: commentCount={}, lastReplyAt={}",
post.getId(), commentCount, lastReplyAt);
}
} }

View File

@@ -109,6 +109,10 @@ public class PostChangeLogService {
logRepository.save(log); logRepository.save(log);
} }
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}
public List<PostChangeLog> listLogs(Long postId) { public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -9,14 +9,12 @@ import com.openisle.repository.PollPostRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository; import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository; import com.openisle.repository.TagRepository;
import com.openisle.service.SubscriptionService;
import com.openisle.service.CommentService;
import com.openisle.service.PostChangeLogService;
import com.openisle.repository.CommentRepository; import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository; import com.openisle.repository.PollVoteRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -74,6 +72,7 @@ public class PostService {
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final PointService pointService; private final PointService pointService;
private final PostChangeLogService postChangeLogService; private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
@@ -102,6 +101,7 @@ public class PostService {
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService, PointService pointService,
PostChangeLogService postChangeLogService, PostChangeLogService postChangeLogService,
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate) { RedisTemplate redisTemplate) {
this.postRepository = postRepository; this.postRepository = postRepository;
@@ -125,6 +125,7 @@ public class PostService {
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.pointService = pointService; this.pointService = pointService;
this.postChangeLogService = postChangeLogService; this.postChangeLogService = postChangeLogService;
this.pointHistoryRepository = pointHistoryRepository;
this.publishMode = publishMode; this.publishMode = publishMode;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
@@ -190,7 +191,7 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict( @CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true value = CachingConfig.POST_CACHE_NAME, allEntries = true
) )
public Post createPost(String username, public Post createPost(String username,
Long categoryId, Long categoryId,
@@ -861,6 +862,25 @@ public class PostService {
notificationRepository.deleteAll(notificationRepository.findByPost(post)); notificationRepository.deleteAll(notificationRepository.findByPost(post));
postReadService.deleteByPost(post); postReadService.deleteByPost(post);
imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); imageUploader.removeReferences(imageUploader.extractUrls(post.getContent()));
List<PointHistory> pointHistories = pointHistoryRepository.findByPost(post);
Set<User> usersToRecalculate = pointHistories.stream()
.map(PointHistory::getUser)
.collect(Collectors.toSet());
if (!pointHistories.isEmpty()) {
LocalDateTime deletedAt = LocalDateTime.now();
for (PointHistory history : pointHistories) {
history.setDeletedAt(deletedAt);
history.setPost(null);
}
pointHistoryRepository.saveAll(pointHistories);
}
if (!usersToRecalculate.isEmpty()) {
for (User affected : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(affected);
affected.setPoint(newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
if (post instanceof LotteryPost lp) { if (post instanceof LotteryPost lp) {
ScheduledFuture<?> future = scheduledFinalizations.remove(lp.getId()); ScheduledFuture<?> future = scheduledFinalizations.remove(lp.getId());
if (future != null) { if (future != null) {
@@ -868,6 +888,7 @@ public class PostService {
} }
} }
String title = post.getTitle(); String title = post.getTitle();
postChangeLogService.deleteLogsForPost(post);
postRepository.delete(post); postRepository.delete(post);
if (adminDeleting) { if (adminDeleting) {
notificationService.createNotification(author, NotificationType.POST_DELETED, notificationService.createNotification(author, NotificationType.POST_DELETED,

View File

@@ -100,7 +100,7 @@ public class UserService {
* @param user * @param user
*/ */
public void sendVerifyMail(User user, VerifyType verifyType){ public void sendVerifyMail(User user, VerifyType verifyType){
//缓存验证码 // 缓存验证码
String code = genCode(); String code = genCode();
String key; String key;
String subject; String subject;

View File

@@ -0,0 +1,19 @@
-- Add comment count and last reply time fields to posts table for performance optimization
ALTER TABLE posts ADD COLUMN comment_count BIGINT NOT NULL DEFAULT 0;
ALTER TABLE posts ADD COLUMN last_reply_at DATETIME(6) NULL;
-- Add index on last_reply_at for sorting by latest reply
CREATE INDEX idx_posts_last_reply_at ON posts(last_reply_at);
-- Initialize comment_count and last_reply_at with existing data
UPDATE posts p SET
comment_count = (
SELECT COUNT(*)
FROM comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
),
last_reply_at = (
SELECT MAX(c.created_at)
FROM comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
);

View File

@@ -55,6 +55,10 @@ class PostControllerTest {
private UserVisitService userVisitService; private UserVisitService userVisitService;
@MockBean @MockBean
private PostReadService postReadService; private PostReadService postReadService;
@MockBean
private MedalService medalService;
@MockBean
private com.openisle.repository.PollVoteRepository pollVoteRepository;
@Test @Test
void createAndGetPost() throws Exception { void createAndGetPost() throws Exception {
@@ -63,9 +67,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");
@@ -111,9 +119,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t2"); post.setTitle("t2");
@@ -147,9 +159,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(2L); post.setId(2L);
post.setTitle("hello"); post.setTitle("hello");
@@ -197,9 +213,13 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Tag tag = new Tag(); Tag tag = new Tag();
tag.setId(1L); tag.setId(1L);
tag.setName("java"); tag.setName("java");
tag.setDescription("Java programming language");
tag.setIcon("java-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");
@@ -262,6 +282,8 @@ class PostControllerTest {
Category cat = new Category(); Category cat = new Category();
cat.setId(1L); cat.setId(1L);
cat.setName("tech"); cat.setName("tech");
cat.setDescription("Technology category");
cat.setIcon("tech-icon");
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);
post.setTitle("t"); post.setTitle("t");

View File

@@ -0,0 +1,90 @@
package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestPropertySource(locations = "classpath:application.properties")
@Transactional
public class PostCommentStatsTest {
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private TagRepository tagRepository;
@Autowired
private CommentService commentService;
@Test
public void testPostCommentStatsUpdate() {
// Create test user
User user = new User();
user.setUsername("testuser");
user.setEmail("test@example.com");
user.setPassword("hash");
user = userRepository.save(user);
// Create test category
Category category = new Category();
category.setName("Test Category");
category.setDescription("Test Category Description");
category.setIcon("test-icon");
category = categoryRepository.save(category);
// Create test tag
Tag tag = new Tag();
tag.setName("Test Tag");
tag.setDescription("Test Tag Description");
tag.setIcon("test-tag-icon");
tag = tagRepository.save(tag);
// Create test post
Post post = new Post();
post.setTitle("Test Post");
post.setContent("Test content");
post.setAuthor(user);
post.setCategory(category);
post.getTags().add(tag);
post.setStatus(PostStatus.PUBLISHED);
post.setCommentCount(0L);
post = postRepository.save(post);
// Verify initial state
assertEquals(0L, post.getCommentCount());
assertNull(post.getLastReplyAt());
// Add a comment
commentService.addComment("testuser", post.getId(), "Test comment");
// Refresh post from database
post = postRepository.findById(post.getId()).orElseThrow();
// Verify comment count and last reply time are updated
assertEquals(1L, post.getCommentCount());
assertNotNull(post.getLastReplyAt());
// Add another comment
commentService.addComment("testuser", post.getId(), "Another comment");
// Refresh post again
post = postRepository.findById(post.getId()).orElseThrow();
// Verify comment count is updated
assertEquals(2L, post.getCommentCount());
}
}

View File

@@ -10,8 +10,11 @@ import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import java.util.Optional; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -39,12 +42,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -60,11 +65,13 @@ class PostServiceTest {
when(reactionRepo.findByPost(post)).thenReturn(List.of()); when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of()); when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of()); when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "alice"); service.deletePost(1L, "alice");
verify(postReadService).deleteByPost(post); verify(postReadService).deleteByPost(post);
verify(postRepo).delete(post); verify(postRepo).delete(post);
verify(postChangeLogService).deleteLogsForPost(post);
} }
@Test @Test
@@ -90,12 +97,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -117,6 +126,7 @@ class PostServiceTest {
when(reactionRepo.findByPost(post)).thenReturn(List.of()); when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of()); when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of()); when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "admin"); service.deletePost(1L, "admin");
@@ -147,12 +157,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -162,6 +174,77 @@ class PostServiceTest {
null, null, null, null, null, null, null, null, null)); null, null, null, null, null, null, null, null, null));
} }
@Test
void deletePostRemovesPointHistoriesAndRecalculatesPoints() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
post.setId(10L);
User author = new User();
author.setId(20L);
author.setRole(Role.USER);
post.setAuthor(author);
User historyUser = new User();
historyUser.setId(30L);
PointHistory history = new PointHistory();
history.setUser(historyUser);
history.setPost(post);
when(postRepo.findById(10L)).thenReturn(Optional.of(post));
when(userRepo.findByUsername("author")).thenReturn(Optional.of(author));
when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of());
when(pointHistoryRepository.findByPost(post)).thenReturn(List.of(history));
when(pointService.recalculateUserPoints(historyUser)).thenReturn(0);
service.deletePost(10L, "author");
ArgumentCaptor<List<PointHistory>> captor = ArgumentCaptor.forClass(List.class);
verify(pointHistoryRepository).saveAll(captor.capture());
List<PointHistory> savedHistories = captor.getValue();
assertEquals(1, savedHistories.size());
PointHistory savedHistory = savedHistories.get(0);
assertNull(savedHistory.getPost());
assertNotNull(savedHistory.getDeletedAt());
assertTrue(savedHistory.getDeletedAt().isBefore(LocalDateTime.now().plusSeconds(1)));
verify(pointService).recalculateUserPoints(historyUser);
verify(userRepo).saveAll(any());
}
@Test @Test
void finalizeLotteryNotifiesAuthor() { void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class); PostRepository postRepo = mock(PostRepository.class);
@@ -185,12 +268,14 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class); RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -4,7 +4,18 @@ spring.datasource.username=sa
spring.datasource.password= spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=create-drop
springdoc.info.title=openisle
springdoc.info.description=Test API documentation
springdoc.info.version=1.0.0
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization
rabbitmq.queue.durable=true
rabbitmq.sharding.enabled=true
resend.api.key=dummy resend.api.key=dummy
resend.from.email=dummy@example.com
cos.base-url=http://test.example.com cos.base-url=http://test.example.com
cos.secret-id=dummy cos.secret-id=dummy
cos.secret-key=dummy cos.secret-key=dummy
@@ -18,6 +29,7 @@ app.upload.max-size=1048576
app.jwt.secret=TestSecret app.jwt.secret=TestSecret
app.jwt.reason-secret=TestReasonSecret app.jwt.reason-secret=TestReasonSecret
app.jwt.reset-secret=TestResetSecret app.jwt.reset-secret=TestResetSecret
app.jwt.invite-secret=TestInviteSecret
app.jwt.expiration=3600000 app.jwt.expiration=3600000
# Default publish mode for tests # Default publish mode for tests

View File

@@ -2,8 +2,8 @@
--primary-color-hover: rgb(9, 95, 105); --primary-color-hover: rgb(9, 95, 105);
--primary-color: rgb(10, 110, 120); --primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5); --primary-color-disabled: rgba(93, 152, 156, 0.5);
--secondary-color:rgb(255, 255, 255); --secondary-color: rgb(255, 255, 255);
--secondary-color-hover:rgba(165, 255, 255, 0.447); --secondary-color-hover: rgba(10, 111, 120, 0.184);
--new-post-icon-color: rgba(10, 111, 120, 0.598); --new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px; --header-height: 60px;
--header-background-color: white; --header-background-color: white;

View File

@@ -712,13 +712,13 @@ watch(selectedTab, async (val) => {
border-radius: 8px; border-radius: 8px;
padding: 5px 10px; padding: 5px 10px;
color: var(--primary-color); color: var(--primary-color);
background-color: var(--secondary-color);
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
margin-top: 15px; margin-top: 15px;
width: fit-content; width: fit-content;
cursor: pointer; cursor: pointer;
} }
.profile-page-header-unsubscribe-button:hover,
.profile-page-header-send-mail-button:hover { .profile-page-header-send-mail-button:hover {
background-color: var(--secondary-color-hover); background-color: var(--secondary-color-hover);
} }