Compare commits

...

13 Commits

Author SHA1 Message Date
Tim
b64f9ef1f6 fix: 抽奖贴无法看见参与人员 #999 2025-09-18 10:43:55 +08:00
Tim
d26b96ebd1 Merge pull request #997 from nagisa77/codex/add-placeholder-for-no-comments
feat: show placeholder when timeline empty
2025-09-17 21:20:59 +08:00
Tim
13cc981421 feat: show placeholder when timeline empty 2025-09-17 21:19:36 +08:00
Tim
efc8589ca0 Merge pull request #996 from nagisa77/codex/implement-reaction-group-gradient-sorting-zszqdc
feat: sort reactions by popularity
2025-09-17 20:58:31 +08:00
Tim
940690889c feat: sort reactions by popularity 2025-09-17 20:36:18 +08:00
Tim
d46420ef81 Merge pull request #993 from nagisa77/codex/fix-compilation-error-in-postservicetest
Fix PostServiceTest constructor parameters
2025-09-17 14:23:44 +08:00
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
sivdead
1a21ba8935 feat(posts): 优化帖子评论统计性能
- 在 Post 模型中添加 commentCount 和 lastReplyAt 字段
- 在 CommentService 中实现更新帖子评论统计的方法
- 在 PostMapper 中使用 Post 模型中的评论统计字段
- 新增数据库迁移脚本,添加评论统计字段和索引
- 更新相关测试用例
2025-09-12 11:08:59 +08:00
12 changed files with 263 additions and 22 deletions

View File

@@ -1,5 +1,6 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto;
@@ -15,6 +16,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -142,6 +144,10 @@ public class PostController {
@Operation(summary = "List posts", description = "List posts by various filters")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@@ -188,6 +194,10 @@ public class PostController {
@Operation(summary = "Latest reply posts", description = "List posts by latest replies")
@ApiResponse(responseCode = "200", description = "Posts sorted by latest reply",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,

View File

@@ -23,6 +23,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Mapper responsible for converting posts into DTOs. */
@@ -67,7 +68,6 @@ public class PostMapper {
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews());
dto.setCommentCount(commentService.countComments(post.getId()));
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
@@ -82,8 +82,12 @@ public class PostMapper {
List<User> participants = commentService.getParticipants(post.getId(), 5);
dto.setParticipants(participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
LocalDateTime last = commentService.getLastCommentTime(post.getId());
dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
LocalDateTime last = post.getLastReplyAt();
if (last == null) {
commentService.updatePostCommentStats(post);
}
dto.setCommentCount(post.getCommentCount());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setReward(0);
dto.setSubscribed(false);
dto.setType(post.getType());
@@ -96,6 +100,8 @@ public class PostMapper {
l.setPointCost(lp.getPointCost());
l.setStartTime(lp.getStartTime());
l.setEndTime(lp.getEndTime());
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
dto.setLottery(l);
}
@@ -104,6 +110,7 @@ public class PostMapper {
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));

View File

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

View File

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

View File

@@ -323,6 +323,9 @@ public class PostService {
return false;
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
public void joinLottery(Long postId, String username) {
LotteryPost post = lotteryPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -339,6 +342,9 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
@Transactional
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
PollPost post = pollPostRepository.findById(postId)
@@ -376,6 +382,9 @@ public class PostService {
return saved;
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
@Transactional
public void finalizePoll(Long postId) {
scheduledFinalizations.remove(postId);
@@ -395,6 +404,9 @@ public class PostService {
});
}
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
@Transactional
public void finalizeLottery(Long postId) {
log.info("start to finalizeLottery for {}", postId);
@@ -508,10 +520,6 @@ public class PostService {
return listPostsByLatestReply(null, null, page, pageSize);
}
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryIds, #tagIds, #page, #pageSize)"
)
public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
java.util.List<Long> tagIds,
Integer page,
@@ -647,10 +655,6 @@ public class PostService {
* @param pageSize
* @return
*/
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('default', #ids, #tids, #page, #pageSize)"
)
public List<Post> defaultListPosts(List<Long> ids, List<Long> tids, Integer page, Integer pageSize){
boolean hasCategories = !CollectionUtils.isEmpty(ids);
boolean hasTags = !CollectionUtils.isEmpty(tids);

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

@@ -268,12 +268,14 @@ class PostServiceTest {
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, PublishMode.DIRECT, redisTemplate);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService,
pointHistoryRepository, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

View File

@@ -4,7 +4,18 @@ spring.datasource.username=sa
spring.datasource.password=
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.from.email=dummy@example.com
cos.base-url=http://test.example.com
cos.secret-id=dummy
cos.secret-key=dummy
@@ -18,6 +29,7 @@ app.upload.max-size=1048576
app.jwt.secret=TestSecret
app.jwt.reason-secret=TestReasonSecret
app.jwt.reset-secret=TestResetSecret
app.jwt.invite-secret=TestInviteSecret
app.jwt.expiration=3600000
# Default publish mode for tests

View File

@@ -107,14 +107,52 @@ const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
const baseReactionOrder = computed(() => {
if (reactionTypes.value.length) return [...reactionTypes.value]
const order = []
const seen = new Set()
for (const reaction of reactions.value) {
if (!seen.has(reaction.type)) {
seen.add(reaction.type)
order.push(reaction.type)
}
}
return order
})
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const sortedReactionTypes = computed(() => {
const baseOrder = [...baseReactionOrder.value]
for (const type of Object.keys(counts.value)) {
if (!baseOrder.includes(type)) baseOrder.push(type)
}
const withMetadata = baseOrder.map((type, index) => ({
type,
count: counts.value[type] || 0,
index,
}))
const nonZero = withMetadata
.filter((item) => item.count > 0)
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count
return a.index - b.index
})
const zero = withMetadata.filter((item) => item.count === 0)
return [...nonZero, ...zero].map((item) => item.type)
})
const displayedReactions = computed(() => {
return sortedReactionTypes.value
.filter((type) => counts.value[type] > 0)
.slice(0, 3)
.map((type) => ({ type }))
})
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null

View File

@@ -122,7 +122,8 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else class="comments-container">
<BaseTimeline :items="timelineItems">
<BasePlaceholder v-if="timelineItems.length === 0" text="暂无评论" icon="inbox" />
<BaseTimeline v-else :items="timelineItems">
<template #item="{ item }">
<CommentItem
v-if="item.kind === 'comment'"
@@ -184,6 +185,7 @@ import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
@@ -813,9 +815,7 @@ const fetchCommentsAndChangeLog = async () => {
for (const item of data) {
const mappedPayload =
item.kind === 'comment'
? mapComment(item.payload)
: mapChangeLog(item.payload)
item.kind === 'comment' ? mapComment(item.payload) : mapChangeLog(item.payload)
newTimelineItemList.push(mappedPayload)
if (item.kind === 'comment') {