Merge pull request #971 from smallclover/main

缓存功能追加
This commit is contained in:
Tim
2025-09-17 13:43:40 +08:00
committed by GitHub
21 changed files with 403 additions and 139 deletions
+1
View File
@@ -12,6 +12,7 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: Deploy environment: Deploy
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -46,6 +46,8 @@ public class CachingConfig {
public static final String LIMIT_CACHE_NAME="openisle_limit"; public static final String LIMIT_CACHE_NAME="openisle_limit";
// 用户访问统计 // 用户访问统计
public static final String VISIT_CACHE_NAME="openisle_visit"; public static final String VISIT_CACHE_NAME="openisle_visit";
// 文章缓存
public static final String POST_CACHE_NAME="openisle_posts";
/** /**
* 自定义Redis的序列化器 * 自定义Redis的序列化器
@@ -65,7 +67,10 @@ public class CachingConfig {
// Hibernate6Module 可以自动处理懒加载代理对象。 // Hibernate6Module 可以自动处理懒加载代理对象。
// Tag对象的creator是FetchType.LAZY // Tag对象的creator是FetchType.LAZY
objectMapper.registerModule(new Hibernate6Module() 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的时候带上类型信息 // service的时候带上类型信息
// 启用类型信息,避免 LinkedHashMap 问题 // 启用类型信息,避免 LinkedHashMap 问题
objectMapper.activateDefaultTyping( objectMapper.activateDefaultTyping(
@@ -92,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)
@@ -1,13 +1,14 @@
package com.openisle.controller; 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.model.Comment;
import com.openisle.dto.CommentDto; import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest; import com.openisle.dto.CommentRequest;
import com.openisle.mapper.CommentMapper; import com.openisle.mapper.CommentMapper;
import com.openisle.service.CaptchaService; import com.openisle.model.CommentSort;
import com.openisle.service.CommentService; import com.openisle.service.*;
import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; 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.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -34,6 +37,8 @@ public class CommentController {
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final PointService pointService; private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
private boolean captchaEnabled; private boolean captchaEnabled;
@@ -85,15 +90,43 @@ public class CommentController {
@GetMapping("/posts/{postId}/comments") @GetMapping("/posts/{postId}/comments")
@Operation(summary = "List comments", description = "List comments for a post") @Operation(summary = "List comments", description = "List comments for a post")
@ApiResponse(responseCode = "200", description = "Comments", @ApiResponse(responseCode = "200", description = "Comments",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentDto.class)))) content = @Content(array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))))
public List<CommentDto> listComments(@PathVariable Long postId, public List<TimelineItemDto<?>> listComments(@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) { @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) {
log.debug("listComments called for post {} with sort {}", postId, sort); log.debug("listComments called for post {} with sort {}", postId, sort);
List<CommentDto> list = commentService.getCommentsForPost(postId, sort).stream() List<CommentDto> commentDtoList = commentService.getCommentsForPost(postId, sort).stream()
.map(commentMapper::toDtoWithReplies) .map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList()); .collect(Collectors.toList());
log.debug("listComments returning {} comments", list.size()); List<PostChangeLogDto> postChangeLogDtoList = changeLogService.listLogs(postId).stream()
return list; .map(postChangeLogMapper::toDto)
.collect(Collectors.toList());
List<TimelineItemDto<?>> 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<TimelineItemDto<?>> 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}") @DeleteMapping("/comments/{id}")
@@ -27,6 +27,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PostController { public class PostController {
private final PostService postService; private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final LevelService levelService; private final LevelService levelService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final DraftService draftService; private final DraftService draftService;
@@ -147,33 +149,16 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
// } // }
boolean hasCategories = ids != null && !ids.isEmpty(); return postService.defaultListPosts(ids,tids,page, pageSize).stream()
boolean hasTags = tids != null && !tids.isEmpty(); .map(postMapper::toSummaryDto).collect(Collectors.toList());
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());
} }
@GetMapping("/ranking") @GetMapping("/ranking")
@@ -187,14 +172,9 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
@@ -215,21 +195,16 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
// } // }
return postService.listPostsByLatestReply(ids, tids, page, pageSize) List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }
@GetMapping("/featured") @GetMapping("/featured")
@@ -243,14 +218,9 @@ public class PostController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) { Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) { List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
ids = java.util.List.of(categoryId); List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
// 只需要在请求的一开始统计一次 // 只需要在请求的一开始统计一次
// if (auth != null) { // if (auth != null) {
// userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
@@ -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<T> {
private Long id;
private String kind; // "comment" | "log"
private LocalDateTime createdAt;
private T payload; // 泛型,具体类型由外部决定
}
@@ -96,8 +96,6 @@ public class PostMapper {
l.setPointCost(lp.getPointCost()); l.setPointCost(lp.getPointCost());
l.setStartTime(lp.getStartTime()); l.setStartTime(lp.getStartTime());
l.setEndTime(lp.getEndTime()); 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); dto.setLottery(l);
} }
@@ -106,7 +104,6 @@ public class PostMapper {
p.setOptions(pp.getOptions()); p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes()); p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime()); 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() Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex, .collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
@@ -39,19 +39,19 @@ public class Post {
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "author_id") @JoinColumn(name = "author_id")
private User author; private User author;
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "category_id") @JoinColumn(name = "category_id")
private Category category; private Category category;
@ManyToMany(fetch = FetchType.LAZY) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "post_tags", @JoinTable(name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"), joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")) inverseJoinColumns = @JoinColumn(name = "tag_id"))
private java.util.Set<Tag> tags = new java.util.HashSet<>(); private Set<Tag> tags = new HashSet<>();
@Column(nullable = false) @Column(nullable = false)
private long views = 0; private long views = 0;
@@ -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;
@@ -16,4 +17,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
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);
} }
@@ -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);
} }
@@ -27,12 +27,12 @@ public class UserVisitScheduler {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserVisitRepository userVisitRepository; private final UserVisitRepository userVisitRepository;
@Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行 @Scheduled(cron = "0 5 0 * * ?") // 每天 00:05 执行
public void persistDailyVisits(){ public void persistDailyVisits(){
LocalDate yesterday = LocalDate.now().minusDays(1); LocalDate yesterday = LocalDate.now().minusDays(1);
String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday; String key = CachingConfig.VISIT_CACHE_NAME + ":" + yesterday;
Set<String> usernames = redisTemplate.opsForSet().members(key); Set<String> usernames = redisTemplate.opsForSet().members(key);
if(!CollectionUtils.isEmpty(usernames)){ if (!CollectionUtils.isEmpty(usernames)) {
for(String username: usernames){ for(String username: usernames){
User user = userRepository.findByUsername(username).orElse(null); User user = userRepository.findByUsername(username).orElse(null);
if(user != null){ if(user != null){
@@ -62,4 +62,18 @@ public class CategoryService {
public List<Category> listCategories() { public List<Category> listCategories() {
return categoryRepository.findAll(); return categoryRepository.findAll();
} }
/**
* 获取检索用的分类Id列表
* @param categoryIds
* @param categoryId
* @return
*/
public List<Long> getSearchCategoryIds(List<Long> categoryIds, Long categoryId){
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = List.of(categoryId);
}
return ids;
}
} }
@@ -1,5 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.User; import com.openisle.model.User;
@@ -20,6 +21,8 @@ import com.openisle.model.Role;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -47,6 +50,10 @@ public class CommentService {
private final PointService pointService; private final PointService pointService;
private final ImageUploader imageUploader; private final ImageUploader imageUploader;
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public Comment addComment(String username, Long postId, String content) { public Comment addComment(String username, Long postId, String content) {
log.debug("addComment called by user {} for post {}", username, postId); log.debug("addComment called by user {} for post {}", username, postId);
@@ -95,6 +102,10 @@ public class CommentService {
return commentRepository.findLastCommentTimeOfUserByUserId(userId); return commentRepository.findLastCommentTimeOfUserByUserId(userId);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public Comment addReply(String username, Long parentId, String content) { public Comment addReply(String username, Long parentId, String content) {
log.debug("addReply called by user {} for parent comment {}", username, parentId); log.debug("addReply called by user {} for parent comment {}", username, parentId);
@@ -228,6 +239,10 @@ public class CommentService {
return count; return count;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public void deleteComment(String username, Long id) { public void deleteComment(String username, Long id) {
log.debug("deleteComment called by user {} for comment {}", username, 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); log.debug("deleteComment completed for comment {}", id);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional @Transactional
public void deleteCommentCascade(Comment comment) { public void deleteCommentCascade(Comment comment) {
log.debug("deleteCommentCascade called for comment {}", comment.getId()); log.debug("deleteCommentCascade called for comment {}", comment.getId());
@@ -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"));
@@ -1,36 +1,26 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig; import com.openisle.config.CachingConfig;
import com.openisle.model.Post; import com.openisle.mapper.PostMapper;
import com.openisle.model.PostStatus; import com.openisle.model.*;
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.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository; 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.model.Role; 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;
import org.springframework.beans.factory.annotation.Value; 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.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -52,6 +42,8 @@ import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.stream.Collectors;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
@@ -80,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;
@@ -108,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;
@@ -131,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;
@@ -195,12 +190,14 @@ public class PostService {
pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId());
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME, allEntries = true
)
public Post createPost(String username, public Post createPost(String username,
Long categoryId, Long categoryId,
String title, String title,
String content, String content,
java.util.List<Long> tagIds, List<Long> tagIds,
PostType type, PostType type,
String prizeDescription, String prizeDescription,
String prizeIcon, String prizeIcon,
@@ -511,6 +508,10 @@ public class PostService {
return listPostsByLatestReply(null, null, page, pageSize); 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, public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
java.util.List<Long> tagIds, java.util.List<Long> tagIds,
Integer page, Integer page,
@@ -538,9 +539,9 @@ public class PostService {
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
} }
} else { } else {
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds); List<Tag> tags = tagRepository.findAllById(tagIds);
if (tags.isEmpty()) { if (tags.isEmpty()) {
return java.util.List.of(); return new ArrayList<>();
} }
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
} }
@@ -638,11 +639,43 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize); 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<Post> defaultListPosts(List<Long> ids, List<Long> 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<Post> listPendingPosts() { public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING); return postRepository.findByStatus(PostStatus.PENDING);
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post approvePost(Long id) { public Post approvePost(Long id) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -679,6 +712,10 @@ public class PostService {
return post; return post;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post pinPost(Long id, String username) { public Post pinPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -691,6 +728,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post unpinPost(Long id, String username) { public Post unpinPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -703,6 +744,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post closePost(Long id, String username) { public Post closePost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -718,6 +763,10 @@ public class PostService {
return saved; return saved;
} }
@CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
public Post reopenPost(Long id, String username) { public Post reopenPost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -733,7 +782,11 @@ public class PostService {
return saved; return saved;
} }
@org.springframework.transaction.annotation.Transactional @CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public Post updatePost(Long id, public Post updatePost(Long id,
String username, String username,
Long categoryId, Long categoryId,
@@ -786,7 +839,11 @@ public class PostService {
return updated; return updated;
} }
@org.springframework.transaction.annotation.Transactional @CacheEvict(
value = CachingConfig.POST_CACHE_NAME,
allEntries = true
)
@Transactional
public void deletePost(Long id, String username) { public void deletePost(Long id, String username) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -805,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) {
@@ -812,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,
@@ -879,15 +956,17 @@ public class PostService {
.toList(); .toList();
} }
private java.util.List<Post> paginate(java.util.List<Post> posts, Integer page, Integer pageSize) { private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) { if (page == null || pageSize == null) {
return posts; return posts;
} }
int from = page * pageSize; int from = page * pageSize;
if (from >= posts.size()) { if (from >= posts.size()) {
return java.util.List.of(); return new ArrayList<>();
} }
int to = Math.min(from + pageSize, posts.size()); int to = Math.min(from + pageSize, posts.size());
return posts.subList(from, to); // 这里必须将list包装为arrayList类型,否则序列化会有问题
// list.sublist返回的是内部类
return new ArrayList<>(posts.subList(from, to));
} }
} }
@@ -120,4 +120,18 @@ public class TagService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return tagRepository.findByCreator(user); return tagRepository.findByCreator(user);
} }
/**
* 获取检索用的标签Id列表
* @param tagIds
* @param tagId
* @return
*/
public List<Long> getSearchTagIds(List<Long> tagIds, Long tagId){
List<Long> ids = tagIds;
if (tagId != null) {
ids = List.of(tagId);
}
return ids;
}
} }
@@ -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;
@@ -49,9 +49,9 @@ public class UserVisitService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .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); Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if(cached != null){ if (cached != null){
return cached.longValue(); return cached.longValue();
} }
@@ -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);
+2
View File
@@ -2,6 +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-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;
+31 -35
View File
@@ -320,6 +320,7 @@ const mapComment = (
level = 0, level = 0,
) => ({ ) => ({
id: c.id, id: c.id,
kind: 'comment',
userName: c.author.username, userName: c.author.username,
medal: c.author.displayMedal, medal: c.author.displayMedal,
userId: c.author.id, userId: c.author.id,
@@ -374,6 +375,7 @@ const changeLogIcon = (l) => {
const mapChangeLog = (l) => ({ const mapChangeLog = (l) => ({
id: l.id, id: l.id,
kind: 'log',
username: l.username, username: l.username,
userAvatar: l.userAvatar, userAvatar: l.userAvatar,
type: l.type, type: l.type,
@@ -788,9 +790,9 @@ const fetchCommentSorts = () => {
]) ])
} }
const fetchComments = async () => { const fetchCommentsAndChangeLog = async () => {
isFetchingComments.value = true isFetchingComments.value = true
console.debug('Fetching comments', { postId, sort: commentSort.value }) console.info('Fetching comments and chang log', { postId, sort: commentSort.value })
try { try {
const token = getToken() const token = getToken()
const res = await fetch( const res = await fetch(
@@ -799,11 +801,34 @@ const fetchComments = async () => {
headers: { Authorization: token ? `Bearer ${token}` : '' }, headers: { Authorization: token ? `Bearer ${token}` : '' },
}, },
) )
console.debug('Fetch comments response status', res.status) console.info('Fetch comments response status', res.status)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
console.debug('Fetched comments count', data.length) console.info('Fetched comments data', data)
comments.value = data.map(mapComment)
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 isFetchingComments.value = false
await nextTick() await nextTick()
gatherPostItems() 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 () => { const fetchTimeline = async () => {
await Promise.all([fetchComments(), fetchChangeLogs()]) await fetchCommentsAndChangeLog()
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),
)
}
} }
watch(commentSort, async () => { watch(commentSort, async () => {
+21 -1
View File
@@ -29,7 +29,7 @@
<reduce-user /> <reduce-user />
取消关注 取消关注
</div> </div>
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage"> <div v-if="!isMine" class="profile-page-header-send-mail-button" @click="sendMessage">
<message-one /> <message-one />
发私信 发私信
</div> </div>
@@ -703,6 +703,26 @@ watch(selectedTab, async (val) => {
cursor: pointer; 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 { .profile-level-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;