diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 2b9fae188..f62be188d 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -12,6 +12,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest environment: Deploy + if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行 steps: - uses: actions/checkout@v4 diff --git a/backend/src/main/java/com/openisle/config/CachingConfig.java b/backend/src/main/java/com/openisle/config/CachingConfig.java index 7497d1024..70c14f593 100644 --- a/backend/src/main/java/com/openisle/config/CachingConfig.java +++ b/backend/src/main/java/com/openisle/config/CachingConfig.java @@ -46,6 +46,8 @@ public class CachingConfig { public static final String LIMIT_CACHE_NAME="openisle_limit"; // 用户访问统计 public static final String VISIT_CACHE_NAME="openisle_visit"; + // 文章缓存 + public static final String POST_CACHE_NAME="openisle_posts"; /** * 自定义Redis的序列化器 @@ -65,7 +67,10 @@ public class CachingConfig { // Hibernate6Module 可以自动处理懒加载代理对象。 // Tag对象的creator是FetchType.LAZY objectMapper.registerModule(new Hibernate6Module() - .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)); + .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION) + // 将 Hibernate 特有的集合类型转换为标准 Java 集合类型 + // 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息 + .configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true)); // service的时候带上类型信息 // 启用类型信息,避免 LinkedHashMap 问题 objectMapper.activateDefaultTyping( @@ -92,8 +97,10 @@ public class CachingConfig { // 个别缓存单独设置 TTL 时间 Map cacheConfigs = new HashMap<>(); RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); + RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10)); cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig); + cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index d611e9622..fb53f35d2 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -1,13 +1,14 @@ package com.openisle.controller; +import com.openisle.dto.PostChangeLogDto; +import com.openisle.dto.TimelineItemDto; +import com.openisle.mapper.PostChangeLogMapper; import com.openisle.model.Comment; import com.openisle.dto.CommentDto; import com.openisle.dto.CommentRequest; import com.openisle.mapper.CommentMapper; -import com.openisle.service.CaptchaService; -import com.openisle.service.CommentService; -import com.openisle.service.LevelService; -import com.openisle.service.PointService; +import com.openisle.model.CommentSort; +import com.openisle.service.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -21,6 +22,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -34,6 +37,8 @@ public class CommentController { private final CaptchaService captchaService; private final CommentMapper commentMapper; private final PointService pointService; + private final PostChangeLogService changeLogService; + private final PostChangeLogMapper postChangeLogMapper; @Value("${app.captcha.enabled:false}") private boolean captchaEnabled; @@ -85,15 +90,43 @@ public class CommentController { @GetMapping("/posts/{postId}/comments") @Operation(summary = "List comments", description = "List comments for a post") @ApiResponse(responseCode = "200", description = "Comments", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentDto.class)))) - public List listComments(@PathVariable Long postId, - @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) { + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class)))) + public List> listComments(@PathVariable Long postId, + @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) { log.debug("listComments called for post {} with sort {}", postId, sort); - List list = commentService.getCommentsForPost(postId, sort).stream() + List commentDtoList = commentService.getCommentsForPost(postId, sort).stream() .map(commentMapper::toDtoWithReplies) .collect(Collectors.toList()); - log.debug("listComments returning {} comments", list.size()); - return list; + List postChangeLogDtoList = changeLogService.listLogs(postId).stream() + .map(postChangeLogMapper::toDto) + .collect(Collectors.toList()); + List> itemDtoList = new ArrayList<>(); + + itemDtoList.addAll(commentDtoList.stream() + .map(c -> new TimelineItemDto<>( + c.getId(), + "comment", + c.getCreatedAt(), + c // payload 是 CommentDto + )) + .toList()); + + itemDtoList.addAll(postChangeLogDtoList.stream() + .map(l -> new TimelineItemDto<>( + l.getId(), + "log", + l.getTime(), // 注意字段名不一样 + l // payload 是 PostChangeLogDto + )) + .toList()); + // 排序 + Comparator> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt); + if (CommentSort.NEWEST.equals(sort)) { + comparator = comparator.reversed(); + } + itemDtoList.sort(comparator); + log.debug("listComments returning {} comments", itemDtoList.size()); + return itemDtoList; } @DeleteMapping("/comments/{id}") diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 002455900..963e06bb6 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -27,6 +27,8 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class PostController { private final PostService postService; + private final CategoryService categoryService; + private final TagService tagService; private final LevelService levelService; private final CaptchaService captchaService; private final DraftService draftService; @@ -147,33 +149,16 @@ public class PostController { @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "pageSize", required = false) Integer pageSize, Authentication auth) { - List ids = categoryIds; - if (categoryId != null) { - ids = java.util.List.of(categoryId); - } - List tids = tagIds; - if (tagId != null) { - tids = java.util.List.of(tagId); - } + + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); // 只需要在请求的一开始统计一次 // if (auth != null) { // userVisitService.recordVisit(auth.getName()); // } - boolean hasCategories = ids != null && !ids.isEmpty(); - boolean hasTags = tids != null && !tids.isEmpty(); - - if (hasCategories && hasTags) { - return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); - } - if (hasTags) { - return postService.listPostsByTags(tids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); - } - - return postService.listPostsByCategories(ids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); + return postService.defaultListPosts(ids,tids,page, pageSize).stream() + .map(postMapper::toSummaryDto).collect(Collectors.toList()); } @GetMapping("/ranking") @@ -187,14 +172,9 @@ public class PostController { @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "pageSize", required = false) Integer pageSize, Authentication auth) { - List ids = categoryIds; - if (categoryId != null) { - ids = java.util.List.of(categoryId); - } - List tids = tagIds; - if (tagId != null) { - tids = java.util.List.of(tagId); - } + + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); // 只需要在请求的一开始统计一次 // if (auth != null) { // userVisitService.recordVisit(auth.getName()); @@ -215,21 +195,16 @@ public class PostController { @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "pageSize", required = false) Integer pageSize, Authentication auth) { - List ids = categoryIds; - if (categoryId != null) { - ids = java.util.List.of(categoryId); - } - List tids = tagIds; - if (tagId != null) { - tids = java.util.List.of(tagId); - } + + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); // 只需要在请求的一开始统计一次 // if (auth != null) { // userVisitService.recordVisit(auth.getName()); // } - return postService.listPostsByLatestReply(ids, tids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); + List posts = postService.listPostsByLatestReply(ids, tids, page, pageSize); + return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); } @GetMapping("/featured") @@ -243,14 +218,9 @@ public class PostController { @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "pageSize", required = false) Integer pageSize, Authentication auth) { - List ids = categoryIds; - if (categoryId != null) { - ids = java.util.List.of(categoryId); - } - List tids = tagIds; - if (tagId != null) { - tids = java.util.List.of(tagId); - } + + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); // 只需要在请求的一开始统计一次 // if (auth != null) { // userVisitService.recordVisit(auth.getName()); diff --git a/backend/src/main/java/com/openisle/dto/TimelineItemDto.java b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java new file mode 100644 index 000000000..7e53f8a9c --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java @@ -0,0 +1,20 @@ +package com.openisle.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +/** + * comment and change_log Dto + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class TimelineItemDto { + + private Long id; + private String kind; // "comment" | "log" + private LocalDateTime createdAt; + private T payload; // 泛型,具体类型由外部决定 +} diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index bd4a04826..959129236 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -96,8 +96,6 @@ 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); } @@ -106,7 +104,6 @@ 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> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream() .collect(Collectors.groupingBy(PollVote::getOptionIndex, Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 3dcbbab3c..7e5df2a4f 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -39,19 +39,19 @@ public class Post { columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") private LocalDateTime createdAt; - @ManyToOne(optional = false, fetch = FetchType.LAZY) + @ManyToOne(optional = false, fetch = FetchType.EAGER) @JoinColumn(name = "author_id") private User author; - @ManyToOne(optional = false, fetch = FetchType.LAZY) + @ManyToOne(optional = false, fetch = FetchType.EAGER) @JoinColumn(name = "category_id") private Category category; - @ManyToMany(fetch = FetchType.LAZY) + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "post_tags", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) - private java.util.Set tags = new java.util.HashSet<>(); + private Set tags = new HashSet<>(); @Column(nullable = false) private long views = 0; diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java index 2e3d6647e..876b183d1 100644 --- a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java @@ -1,8 +1,9 @@ package com.openisle.repository; -import com.openisle.model.PointHistory; -import com.openisle.model.User; 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 java.time.LocalDateTime; @@ -14,6 +15,8 @@ public interface PointHistoryRepository extends JpaRepository findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); - + List findByComment(Comment comment); + + List findByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java index c9e700fc0..324ac8506 100644 --- a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java @@ -8,4 +8,6 @@ import java.util.List; public interface PostChangeLogRepository extends JpaRepository { List findByPostOrderByCreatedAtAsc(Post post); + + void deleteByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java b/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java index ff16fe3c4..139963587 100644 --- a/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java +++ b/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java @@ -27,12 +27,12 @@ public class UserVisitScheduler { private final UserRepository userRepository; private final UserVisitRepository userVisitRepository; - @Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行 + @Scheduled(cron = "0 5 0 * * ?") // 每天 00:05 执行 public void persistDailyVisits(){ LocalDate yesterday = LocalDate.now().minusDays(1); - String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday; + String key = CachingConfig.VISIT_CACHE_NAME + ":" + yesterday; Set usernames = redisTemplate.opsForSet().members(key); - if(!CollectionUtils.isEmpty(usernames)){ + if (!CollectionUtils.isEmpty(usernames)) { for(String username: usernames){ User user = userRepository.findByUsername(username).orElse(null); if(user != null){ diff --git a/backend/src/main/java/com/openisle/service/CategoryService.java b/backend/src/main/java/com/openisle/service/CategoryService.java index 9486bc88c..309ae7f0d 100644 --- a/backend/src/main/java/com/openisle/service/CategoryService.java +++ b/backend/src/main/java/com/openisle/service/CategoryService.java @@ -62,4 +62,18 @@ public class CategoryService { public List listCategories() { return categoryRepository.findAll(); } + + /** + * 获取检索用的分类Id列表 + * @param categoryIds + * @param categoryId + * @return + */ + public List getSearchCategoryIds(List categoryIds, Long categoryId){ + List ids = categoryIds; + if (categoryId != null) { + ids = List.of(categoryId); + } + return ids; + } } diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index 1076aa5e2..cf09f2a19 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -1,5 +1,6 @@ package com.openisle.service; +import com.openisle.config.CachingConfig; import com.openisle.model.Comment; import com.openisle.model.Post; import com.openisle.model.User; @@ -20,6 +21,8 @@ import com.openisle.model.Role; import com.openisle.exception.RateLimitException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.List; @@ -47,6 +50,10 @@ public class CommentService { private final PointService pointService; private final ImageUploader imageUploader; + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) @Transactional public Comment addComment(String username, Long postId, String content) { log.debug("addComment called by user {} for post {}", username, postId); @@ -95,6 +102,10 @@ public class CommentService { return commentRepository.findLastCommentTimeOfUserByUserId(userId); } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) @Transactional public Comment addReply(String username, Long parentId, String content) { log.debug("addReply called by user {} for parent comment {}", username, parentId); @@ -228,6 +239,10 @@ public class CommentService { return count; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) @Transactional public void deleteComment(String username, Long id) { log.debug("deleteComment called by user {} for comment {}", username, id); @@ -243,6 +258,10 @@ public class CommentService { log.debug("deleteComment completed for comment {}", id); } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) @Transactional public void deleteCommentCascade(Comment comment) { log.debug("deleteCommentCascade called for comment {}", comment.getId()); diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java index aeb6c2bc5..ec15508d1 100644 --- a/backend/src/main/java/com/openisle/service/PostChangeLogService.java +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -109,6 +109,10 @@ public class PostChangeLogService { logRepository.save(log); } + public void deleteLogsForPost(Post post) { + logRepository.deleteByPost(post); + } + public List listLogs(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 16162d32a..a948bf801 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,36 +1,26 @@ package com.openisle.service; import com.openisle.config.CachingConfig; -import com.openisle.model.Post; -import com.openisle.model.PostStatus; -import com.openisle.model.PostType; -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.model.LotteryPost; -import com.openisle.model.PollPost; -import com.openisle.model.PollVote; +import com.openisle.mapper.PostMapper; +import com.openisle.model.*; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.PollPostRepository; 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.service.PostChangeLogService; import com.openisle.repository.CommentRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.NotificationRepository; import com.openisle.repository.PollVoteRepository; -import com.openisle.model.Role; +import com.openisle.repository.PointHistoryRepository; import com.openisle.exception.RateLimitException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationContext; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -52,6 +42,8 @@ import java.time.LocalDateTime; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; + import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @@ -80,6 +72,7 @@ public class PostService { private final ApplicationContext applicationContext; private final PointService pointService; private final PostChangeLogService postChangeLogService; + private final PointHistoryRepository pointHistoryRepository; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -108,6 +101,7 @@ public class PostService { ApplicationContext applicationContext, PointService pointService, PostChangeLogService postChangeLogService, + PointHistoryRepository pointHistoryRepository, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, RedisTemplate redisTemplate) { this.postRepository = postRepository; @@ -131,6 +125,7 @@ public class PostService { this.applicationContext = applicationContext; this.pointService = pointService; this.postChangeLogService = postChangeLogService; + this.pointHistoryRepository = pointHistoryRepository; this.publishMode = publishMode; this.redisTemplate = redisTemplate; @@ -195,12 +190,14 @@ public class PostService { pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); return saved; } - + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, allEntries = true + ) public Post createPost(String username, Long categoryId, String title, String content, - java.util.List tagIds, + List tagIds, PostType type, String prizeDescription, String prizeIcon, @@ -511,6 +508,10 @@ 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 listPostsByLatestReply(java.util.List categoryIds, java.util.List tagIds, Integer page, @@ -538,9 +539,9 @@ public class PostService { posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); } } else { - java.util.List tags = tagRepository.findAllById(tagIds); + List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { - return java.util.List.of(); + return new ArrayList<>(); } posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); } @@ -638,11 +639,43 @@ public class PostService { return paginate(sortByPinnedAndCreated(posts), page, pageSize); } + /** + * 默认的文章列表 + * @param ids + * @param tids + * @param page + * @param pageSize + * @return + */ + @Cacheable( + value = CachingConfig.POST_CACHE_NAME, + key = "new org.springframework.cache.interceptor.SimpleKey('default', #ids, #tids, #page, #pageSize)" + ) + public List defaultListPosts(List ids, List tids, Integer page, Integer pageSize){ + boolean hasCategories = !CollectionUtils.isEmpty(ids); + boolean hasTags = !CollectionUtils.isEmpty(tids); + + if (hasCategories && hasTags) { + return listPostsByCategoriesAndTags(ids, tids, page, pageSize) + .stream().collect(Collectors.toList()); + } + if (hasTags) { + return listPostsByTags(tids, page, pageSize) + .stream().collect(Collectors.toList()); + } + + return listPostsByCategories(ids, page, pageSize) + .stream().collect(Collectors.toList()); + } public List listPendingPosts() { return postRepository.findByStatus(PostStatus.PENDING); } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) public Post approvePost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -679,6 +712,10 @@ public class PostService { return post; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) public Post pinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -691,6 +728,10 @@ public class PostService { return saved; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) public Post unpinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -703,6 +744,10 @@ public class PostService { return saved; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) public Post closePost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -718,6 +763,10 @@ public class PostService { return saved; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) public Post reopenPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -733,7 +782,11 @@ public class PostService { return saved; } - @org.springframework.transaction.annotation.Transactional + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) + @Transactional public Post updatePost(Long id, String username, Long categoryId, @@ -786,7 +839,11 @@ public class PostService { return updated; } - @org.springframework.transaction.annotation.Transactional + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, + allEntries = true + ) + @Transactional public void deletePost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); @@ -805,6 +862,25 @@ public class PostService { notificationRepository.deleteAll(notificationRepository.findByPost(post)); postReadService.deleteByPost(post); imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); + List pointHistories = pointHistoryRepository.findByPost(post); + Set 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) { ScheduledFuture future = scheduledFinalizations.remove(lp.getId()); if (future != null) { @@ -812,6 +888,7 @@ public class PostService { } } String title = post.getTitle(); + postChangeLogService.deleteLogsForPost(post); postRepository.delete(post); if (adminDeleting) { notificationService.createNotification(author, NotificationType.POST_DELETED, @@ -879,15 +956,17 @@ public class PostService { .toList(); } - private java.util.List paginate(java.util.List posts, Integer page, Integer pageSize) { + private List paginate(List 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(); + return new ArrayList<>(); } int to = Math.min(from + pageSize, posts.size()); - return posts.subList(from, to); + // 这里必须将list包装为arrayList类型,否则序列化会有问题 + // list.sublist返回的是内部类 + return new ArrayList<>(posts.subList(from, to)); } } diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index eee84121e..02191427d 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -120,4 +120,18 @@ public class TagService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); return tagRepository.findByCreator(user); } + + /** + * 获取检索用的标签Id列表 + * @param tagIds + * @param tagId + * @return + */ + public List getSearchTagIds(List tagIds, Long tagId){ + List ids = tagIds; + if (tagId != null) { + ids = List.of(tagId); + } + return ids; + } } diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index a3352901f..4bef6dbc7 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -100,7 +100,7 @@ public class UserService { * @param user */ public void sendVerifyMail(User user, VerifyType verifyType){ - //缓存验证码 + // 缓存验证码 String code = genCode(); String key; String subject; diff --git a/backend/src/main/java/com/openisle/service/UserVisitService.java b/backend/src/main/java/com/openisle/service/UserVisitService.java index 272c846de..9182e145f 100644 --- a/backend/src/main/java/com/openisle/service/UserVisitService.java +++ b/backend/src/main/java/com/openisle/service/UserVisitService.java @@ -49,9 +49,9 @@ public class UserVisitService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); // 如果缓存存在就返回 - String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username; + String key1 = CachingConfig.VISIT_CACHE_NAME + ":" +LocalDate.now() + ":count:" + username; Integer cached = (Integer) redisTemplate.opsForValue().get(key1); - if(cached != null){ + if (cached != null){ return cached.longValue(); } diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 998fe0175..b6997d59e 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -10,8 +10,11 @@ import org.springframework.data.redis.core.RedisTemplate; import static org.junit.jupiter.api.Assertions.*; -import java.util.Optional; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; + +import org.mockito.ArgumentCaptor; import static org.mockito.Mockito.*; @@ -39,12 +42,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); Post post = new Post(); @@ -60,11 +65,13 @@ class PostServiceTest { 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()); service.deletePost(1L, "alice"); verify(postReadService).deleteByPost(post); verify(postRepo).delete(post); + verify(postChangeLogService).deleteLogsForPost(post); } @Test @@ -90,12 +97,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); Post post = new Post(); @@ -117,6 +126,7 @@ class PostServiceTest { 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()); service.deletePost(1L, "admin"); @@ -147,12 +157,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); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); @@ -162,6 +174,77 @@ class PostServiceTest { 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> captor = ArgumentCaptor.forClass(List.class); + verify(pointHistoryRepository).saveAll(captor.capture()); + List 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 void finalizeLotteryNotifiesAuthor() { PostRepository postRepo = mock(PostRepository.class); diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index 6105d1ab9..edb5f246b 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -2,6 +2,8 @@ --primary-color-hover: rgb(9, 95, 105); --primary-color: rgb(10, 110, 120); --primary-color-disabled: rgba(93, 152, 156, 0.5); + --secondary-color: rgb(255, 255, 255); + --secondary-color-hover: rgba(10, 111, 120, 0.184); --new-post-icon-color: rgba(10, 111, 120, 0.598); --header-height: 60px; --header-background-color: white; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 5aa2bf57e..ad31ff183 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -320,6 +320,7 @@ const mapComment = ( level = 0, ) => ({ id: c.id, + kind: 'comment', userName: c.author.username, medal: c.author.displayMedal, userId: c.author.id, @@ -374,6 +375,7 @@ const changeLogIcon = (l) => { const mapChangeLog = (l) => ({ id: l.id, + kind: 'log', username: l.username, userAvatar: l.userAvatar, type: l.type, @@ -788,9 +790,9 @@ const fetchCommentSorts = () => { ]) } -const fetchComments = async () => { +const fetchCommentsAndChangeLog = async () => { isFetchingComments.value = true - console.debug('Fetching comments', { postId, sort: commentSort.value }) + console.info('Fetching comments and chang log', { postId, sort: commentSort.value }) try { const token = getToken() const res = await fetch( @@ -799,11 +801,34 @@ const fetchComments = async () => { headers: { Authorization: token ? `Bearer ${token}` : '' }, }, ) - console.debug('Fetch comments response status', res.status) + console.info('Fetch comments response status', res.status) if (res.ok) { const data = await res.json() - console.debug('Fetched comments count', data.length) - comments.value = data.map(mapComment) + console.info('Fetched comments data', data) + + const commentList = [] + const changeLogList = [] + // 时间线列表,包含评论和日志 + const newTimelineItemList = [] + + for (const item of data) { + const mappedPayload = + item.kind === 'comment' + ? mapComment(item.payload) + : mapChangeLog(item.payload) + newTimelineItemList.push(mappedPayload) + + if (item.kind === 'comment') { + commentList.push(mappedPayload) + } else { + changeLogList.push(mappedPayload) + } + } + + comments.value = commentList + changeLogs.value = changeLogList + timelineItems.value = newTimelineItemList + isFetchingComments.value = false await nextTick() gatherPostItems() @@ -815,37 +840,8 @@ const fetchComments = async () => { } } -const fetchChangeLogs = async () => { - try { - const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/change-logs`) - if (res.ok) { - const data = await res.json() - changeLogs.value = data.map(mapChangeLog) - await nextTick() - gatherPostItems() - } - } catch (e) { - console.debug('Fetch change logs error', e) - } -} - -// -// todo(tim): fetchComments, fetchChangeLogs 整合到一个请求,并且取消前端排序 -// const fetchTimeline = async () => { - await Promise.all([fetchComments(), fetchChangeLogs()]) - const cs = comments.value.map((c) => ({ ...c, kind: 'comment' })) - const ls = changeLogs.value.map((l) => ({ ...l, kind: 'log' })) - - if (commentSort.value === 'NEWEST') { - timelineItems.value = [...cs, ...ls].sort( - (a, b) => new Date(b.createdAt) - new Date(a.createdAt), - ) - } else { - timelineItems.value = [...cs, ...ls].sort( - (a, b) => new Date(a.createdAt) - new Date(b.createdAt), - ) - } + await fetchCommentsAndChangeLog() } watch(commentSort, async () => { diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index 4e810e2b7..b78aa56f6 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -29,7 +29,7 @@ 取消关注 -
+
发私信
@@ -703,6 +703,26 @@ watch(selectedTab, async (val) => { cursor: pointer; } +.profile-page-header-send-mail-button { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + font-size: 14px; + border-radius: 8px; + padding: 5px 10px; + color: var(--primary-color); + border: 1px solid var(--primary-color); + margin-top: 15px; + width: fit-content; + cursor: pointer; +} + +.profile-page-header-unsubscribe-button:hover, +.profile-page-header-send-mail-button:hover { + background-color: var(--secondary-color-hover); +} + .profile-level-container { display: flex; flex-direction: column;