diff --git a/backend/src/main/java/com/openisle/config/SystemUserInitializer.java b/backend/src/main/java/com/openisle/config/SystemUserInitializer.java new file mode 100644 index 000000000..7dfd4cf39 --- /dev/null +++ b/backend/src/main/java/com/openisle/config/SystemUserInitializer.java @@ -0,0 +1,36 @@ +package com.openisle.config; + +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Ensure a dedicated "system" user exists for internal operations. + */ +@Component +@RequiredArgsConstructor +public class SystemUserInitializer implements CommandLineRunner { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public void run(String... args) { + userRepository.findByUsername("system").orElseGet(() -> { + User system = new User(); + system.setUsername("system"); + system.setEmail("system@openisle.local"); + // todo(tim): raw password 采用环境变量 + system.setPassword(passwordEncoder.encode("system")); + system.setRole(Role.USER); + system.setVerified(true); + system.setApproved(true); + system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"); + return userRepository.save(system); + }); + } +} + diff --git a/backend/src/main/java/com/openisle/controller/AdminPostController.java b/backend/src/main/java/com/openisle/controller/AdminPostController.java index 3c9414cad..498c05add 100644 --- a/backend/src/main/java/com/openisle/controller/AdminPostController.java +++ b/backend/src/main/java/com/openisle/controller/AdminPostController.java @@ -37,22 +37,22 @@ public class AdminPostController { } @PostMapping("/{id}/pin") - public PostSummaryDto pin(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.pinPost(id)); + public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.pinPost(id, auth.getName())); } @PostMapping("/{id}/unpin") - public PostSummaryDto unpin(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.unpinPost(id)); + public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName())); } @PostMapping("/{id}/rss-exclude") - public PostSummaryDto excludeFromRss(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.excludeFromRss(id)); + public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName())); } @PostMapping("/{id}/rss-include") - public PostSummaryDto includeInRss(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.includeInRss(id)); + public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName())); } } diff --git a/backend/src/main/java/com/openisle/controller/PostChangeLogController.java b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java new file mode 100644 index 000000000..ae51a9dbb --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java @@ -0,0 +1,25 @@ +package com.openisle.controller; + +import com.openisle.dto.PostChangeLogDto; +import com.openisle.mapper.PostChangeLogMapper; +import com.openisle.service.PostChangeLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostChangeLogController { + private final PostChangeLogService changeLogService; + private final PostChangeLogMapper mapper; + + @GetMapping("/{id}/change-logs") + public List listLogs(@PathVariable Long id) { + return changeLogService.listLogs(id).stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java new file mode 100644 index 000000000..f07373c46 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java @@ -0,0 +1,32 @@ +package com.openisle.dto; + +import com.openisle.model.PostChangeType; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +public class PostChangeLogDto { + private Long id; + private String username; + private String userAvatar; + private PostChangeType type; + private LocalDateTime time; + private String oldTitle; + private String newTitle; + private String oldContent; + private String newContent; + private CategoryDto oldCategory; + private CategoryDto newCategory; + private List oldTags; + private List newTags; + private Boolean oldClosed; + private Boolean newClosed; + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; + private Boolean oldFeatured; + private Boolean newFeatured; +} diff --git a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java new file mode 100644 index 000000000..e7c380256 --- /dev/null +++ b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java @@ -0,0 +1,92 @@ +package com.openisle.mapper; + +import com.openisle.dto.CategoryDto; +import com.openisle.dto.PostChangeLogDto; +import com.openisle.dto.TagDto; +import com.openisle.model.*; +import com.openisle.repository.CategoryRepository; +import com.openisle.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class PostChangeLogMapper { + + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; + + public PostChangeLogDto toDto(PostChangeLog log) { + PostChangeLogDto dto = new PostChangeLogDto(); + dto.setId(log.getId()); + if (log.getUser() != null) { + dto.setUsername(log.getUser().getUsername()); + dto.setUserAvatar(log.getUser().getAvatar()); + } + dto.setType(log.getType()); + dto.setTime(log.getCreatedAt()); + if (log instanceof PostTitleChangeLog t) { + dto.setOldTitle(t.getOldTitle()); + dto.setNewTitle(t.getNewTitle()); + } else if (log instanceof PostContentChangeLog c) { + dto.setOldContent(c.getOldContent()); + dto.setNewContent(c.getNewContent()); + } else if (log instanceof PostCategoryChangeLog cat) { + dto.setOldCategory(mapCategory(cat.getOldCategory())); + dto.setNewCategory(mapCategory(cat.getNewCategory())); + } else if (log instanceof PostTagChangeLog tag) { + dto.setOldTags(mapTags(tag.getOldTags())); + dto.setNewTags(mapTags(tag.getNewTags())); + } else if (log instanceof PostClosedChangeLog cl) { + dto.setOldClosed(cl.isOldClosed()); + dto.setNewClosed(cl.isNewClosed()); + } else if (log instanceof PostPinnedChangeLog p) { + dto.setOldPinnedAt(p.getOldPinnedAt()); + dto.setNewPinnedAt(p.getNewPinnedAt()); + } else if (log instanceof PostFeaturedChangeLog f) { + dto.setOldFeatured(f.isOldFeatured()); + dto.setNewFeatured(f.isNewFeatured()); + } + return dto; + } + + private CategoryDto mapCategory(String name) { + if (name == null) { + return null; + } + return categoryRepository.findByName(name) + .map(categoryMapper::toDto) + .orElseGet(() -> { + CategoryDto dto = new CategoryDto(); + dto.setName(name); + return dto; + }); + } + + private List mapTags(String tags) { + if (tags == null || tags.isBlank()) { + return Collections.emptyList(); + } + return Arrays.stream(tags.split(",")) + .map(String::trim) + .map(this::mapTag) + .collect(Collectors.toList()); + } + + private TagDto mapTag(String name) { + return tagRepository.findByName(name) + .map(tagMapper::toDto) + .orElseGet(() -> { + TagDto dto = new TagDto(); + dto.setName(name); + return dto; + }); + } +} diff --git a/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java new file mode 100644 index 000000000..20264e449 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_category_change_logs") +public class PostCategoryChangeLog extends PostChangeLog { + private String oldCategory; + private String newCategory; +} diff --git a/backend/src/main/java/com/openisle/model/PostChangeLog.java b/backend/src/main/java/com/openisle/model/PostChangeLog.java new file mode 100644 index 000000000..a30cd3b00 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostChangeLog.java @@ -0,0 +1,37 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_change_logs") +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class PostChangeLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "user_id") + private User user; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostChangeType type; +} diff --git a/backend/src/main/java/com/openisle/model/PostChangeType.java b/backend/src/main/java/com/openisle/model/PostChangeType.java new file mode 100644 index 000000000..68b869f7f --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostChangeType.java @@ -0,0 +1,13 @@ +package com.openisle.model; + +public enum PostChangeType { + CONTENT, + TITLE, + CATEGORY, + TAG, + CLOSED, + PINNED, + FEATURED, + VOTE_RESULT, + LOTTERY_RESULT +} diff --git a/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java new file mode 100644 index 000000000..20bbc9cb9 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_closed_change_logs") +public class PostClosedChangeLog extends PostChangeLog { + private boolean oldClosed; + private boolean newClosed; +} diff --git a/backend/src/main/java/com/openisle/model/PostContentChangeLog.java b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java new file mode 100644 index 000000000..1f4170577 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java @@ -0,0 +1,21 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_content_change_logs") +public class PostContentChangeLog extends PostChangeLog { + @Column(name = "old_content", columnDefinition = "LONGTEXT") + private String oldContent; + + @Column(name = "new_content", columnDefinition = "LONGTEXT") + private String newContent; +} diff --git a/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java new file mode 100644 index 000000000..522dba086 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_featured_change_logs") +public class PostFeaturedChangeLog extends PostChangeLog { + private boolean oldFeatured; + private boolean newFeatured; +} diff --git a/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java new file mode 100644 index 000000000..138d9d9cb --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java @@ -0,0 +1,16 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_lottery_result_change_logs") +public class PostLotteryResultChangeLog extends PostChangeLog { +} + diff --git a/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java new file mode 100644 index 000000000..395e0f499 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java @@ -0,0 +1,19 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_pinned_change_logs") +public class PostPinnedChangeLog extends PostChangeLog { + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; +} diff --git a/backend/src/main/java/com/openisle/model/PostTagChangeLog.java b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java new file mode 100644 index 000000000..f17f53ad3 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java @@ -0,0 +1,21 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_tag_change_logs") +public class PostTagChangeLog extends PostChangeLog { + @Column(name = "old_tags") + private String oldTags; + + @Column(name = "new_tags") + private String newTags; +} diff --git a/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java new file mode 100644 index 000000000..f7e6dcb2e --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_title_change_logs") +public class PostTitleChangeLog extends PostChangeLog { + private String oldTitle; + private String newTitle; +} diff --git a/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java new file mode 100644 index 000000000..32a61c210 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java @@ -0,0 +1,16 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_vote_result_change_logs") +public class PostVoteResultChangeLog extends PostChangeLog { +} + diff --git a/backend/src/main/java/com/openisle/repository/CategoryRepository.java b/backend/src/main/java/com/openisle/repository/CategoryRepository.java index b813421d8..ec93f05b9 100644 --- a/backend/src/main/java/com/openisle/repository/CategoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/CategoryRepository.java @@ -4,7 +4,10 @@ import com.openisle.model.Category; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface CategoryRepository extends JpaRepository { List findByNameContainingIgnoreCase(String keyword); + + Optional findByName(String name); } diff --git a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java new file mode 100644 index 000000000..c9e700fc0 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java @@ -0,0 +1,11 @@ +package com.openisle.repository; + +import com.openisle.model.Post; +import com.openisle.model.PostChangeLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostChangeLogRepository extends JpaRepository { + List findByPostOrderByCreatedAtAsc(Post post); +} diff --git a/backend/src/main/java/com/openisle/repository/TagRepository.java b/backend/src/main/java/com/openisle/repository/TagRepository.java index 24414fa74..1e2868437 100644 --- a/backend/src/main/java/com/openisle/repository/TagRepository.java +++ b/backend/src/main/java/com/openisle/repository/TagRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Optional; public interface TagRepository extends JpaRepository { List findByNameContainingIgnoreCase(String keyword); @@ -15,4 +16,6 @@ public interface TagRepository extends JpaRepository { List findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable); List findByCreator(User creator); + + Optional findByName(String name); } diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java new file mode 100644 index 000000000..aeb6c2bc5 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -0,0 +1,117 @@ +package com.openisle.service; + +import com.openisle.model.*; +import com.openisle.repository.PostChangeLogRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostChangeLogService { + private final PostChangeLogRepository logRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + private User getSystemUser() { + return userRepository.findByUsername("system") + .orElseThrow(() -> new IllegalStateException("System user not found")); + } + + public void recordContentChange(Post post, User user, String oldContent, String newContent) { + PostContentChangeLog log = new PostContentChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CONTENT); + log.setOldContent(oldContent); + log.setNewContent(newContent); + logRepository.save(log); + } + + public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) { + PostTitleChangeLog log = new PostTitleChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TITLE); + log.setOldTitle(oldTitle); + log.setNewTitle(newTitle); + logRepository.save(log); + } + + public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) { + PostCategoryChangeLog log = new PostCategoryChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CATEGORY); + log.setOldCategory(oldCategory); + log.setNewCategory(newCategory); + logRepository.save(log); + } + + public void recordTagChange(Post post, User user, Set oldTags, Set newTags) { + PostTagChangeLog log = new PostTagChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TAG); + log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + logRepository.save(log); + } + + public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) { + PostClosedChangeLog log = new PostClosedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CLOSED); + log.setOldClosed(oldClosed); + log.setNewClosed(newClosed); + logRepository.save(log); + } + + public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) { + PostPinnedChangeLog log = new PostPinnedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.PINNED); + log.setOldPinnedAt(oldPinnedAt); + log.setNewPinnedAt(newPinnedAt); + logRepository.save(log); + } + + public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) { + PostFeaturedChangeLog log = new PostFeaturedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.FEATURED); + log.setOldFeatured(oldFeatured); + log.setNewFeatured(newFeatured); + logRepository.save(log); + } + + public void recordVoteResult(Post post) { + PostVoteResultChangeLog log = new PostVoteResultChangeLog(); + log.setPost(post); + log.setUser(getSystemUser()); + log.setType(PostChangeType.VOTE_RESULT); + logRepository.save(log); + } + + public void recordLotteryResult(Post post) { + PostLotteryResultChangeLog log = new PostLotteryResultChangeLog(); + log.setPost(post); + log.setUser(getSystemUser()); + log.setType(PostChangeType.LOTTERY_RESULT); + logRepository.save(log); + } + + public List listLogs(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + return logRepository.findByPostOrderByCreatedAtAsc(post); + } +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index b0016427e..2f51fbad7 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -19,6 +19,7 @@ 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; @@ -74,6 +75,7 @@ public class PostService { private final EmailSender emailSender; private final ApplicationContext applicationContext; private final PointService pointService; + private final PostChangeLogService postChangeLogService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -99,6 +101,7 @@ public class PostService { EmailSender emailSender, ApplicationContext applicationContext, PointService pointService, + PostChangeLogService postChangeLogService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -120,6 +123,7 @@ public class PostService { this.emailSender = emailSender; this.applicationContext = applicationContext; this.pointService = pointService; + this.postChangeLogService = postChangeLogService; this.publishMode = publishMode; } @@ -159,19 +163,28 @@ public class PostService { return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); } - public Post excludeFromRss(Long id) { + public Post excludeFromRss(Long id, String username) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(true); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false); + return saved; } - public Post includeInRss(Long id) { + public Post includeInRss(Long id, String username) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(false); - post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null); - pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId()); - return post; + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true); + notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null); + pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); + return saved; } public Post createPost(String username, @@ -355,6 +368,7 @@ public class PostService { for (User participant : pp.getParticipants()) { notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null); } + postChangeLogService.recordVoteResult(pp); }); } @@ -389,6 +403,7 @@ public class PostService { notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null); notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId())); } + postChangeLogService.recordLotteryResult(lp); }); } @@ -638,18 +653,28 @@ public class PostService { return post; } - public Post pinPost(Long id) { + public Post pinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(java.time.LocalDateTime.now()); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt()); + return saved; } - public Post unpinPost(Long id) { + public Post unpinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(null); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, null); + return saved; } public Post closePost(Long id, String username) { @@ -660,8 +685,11 @@ public class PostService { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); post.setClosed(true); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, true); + return saved; } public Post reopenPost(Long id, String username) { @@ -672,8 +700,11 @@ public class PostService { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); post.setClosed(false); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, false); + return saved; } @org.springframework.transaction.annotation.Transactional @@ -702,14 +733,30 @@ public class PostService { if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } - post.setTitle(title); + String oldTitle = post.getTitle(); String oldContent = post.getContent(); + Category oldCategory = post.getCategory(); + java.util.Set oldTags = new java.util.HashSet<>(post.getTags()); + post.setTitle(title); post.setContent(content); post.setCategory(category); post.setTags(new java.util.HashSet<>(tags)); Post updated = postRepository.save(post); imageUploader.adjustReferences(oldContent, content); notificationService.notifyMentions(content, user, updated, null); + if (!java.util.Objects.equals(oldTitle, title)) { + postChangeLogService.recordTitleChange(updated, user, oldTitle, title); + } + if (!java.util.Objects.equals(oldContent, content)) { + postChangeLogService.recordContentChange(updated, user, oldContent, content); + } + if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) { + postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName()); + } + java.util.Set newTags = new java.util.HashSet<>(tags); + if (!oldTags.equals(newTags)) { + postChangeLogService.recordTagChange(updated, user, oldTags, newTags); + } return updated; } diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue new file mode 100644 index 000000000..bc56d5934 --- /dev/null +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend_nuxt/package-lock.json b/frontend_nuxt/package-lock.json index 1e9ca8f91..4c8d81069 100644 --- a/frontend_nuxt/package-lock.json +++ b/frontend_nuxt/package-lock.json @@ -10,6 +10,8 @@ "@nuxt/image": "^1.11.0", "@stomp/stompjs": "^7.0.0", "cropperjs": "^1.6.2", + "diff": "^8.0.2", + "diff2html": "^3.4.52", "echarts": "^5.6.0", "flatpickr": "^4.6.13", "highlight.js": "^11.11.1", @@ -7218,6 +7220,41 @@ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "license": "Apache-2.0" }, + "node_modules/diff2html": { + "version": "3.4.52", + "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.52.tgz", + "integrity": "sha512-qhMg8/I3sZ4zm/6R/Kh0xd6qG6Vm86w6M+C9W+DuH1V8ACz+1cgEC8/k0ucjv6AGqZWzHm/8G1gh7IlrUqCMhg==", + "license": "MIT", + "dependencies": { + "diff": "^7.0.0", + "hogan.js": "3.0.2" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "highlight.js": "11.9.0" + } + }, + "node_modules/diff2html/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff2html/node_modules/highlight.js": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", + "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8291,6 +8328,49 @@ "node": ">=12.0.0" } }, + "node_modules/hogan.js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", + "integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==", + "dependencies": { + "mkdirp": "0.3.0", + "nopt": "1.0.10" + }, + "bin": { + "hulk": "bin/hulk" + } + }, + "node_modules/hogan.js/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/hogan.js/node_modules/mkdirp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", + "integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==", + "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/hogan.js/node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "license": "MIT", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", diff --git a/frontend_nuxt/package.json b/frontend_nuxt/package.json index 8df2c955a..65385285c 100644 --- a/frontend_nuxt/package.json +++ b/frontend_nuxt/package.json @@ -16,6 +16,8 @@ "@nuxt/image": "^1.11.0", "@stomp/stompjs": "^7.0.0", "cropperjs": "^1.6.2", + "diff": "^8.0.2", + "diff2html": "^3.4.52", "echarts": "^5.6.0", "flatpickr": "^4.6.13", "highlight.js": "^11.11.1", diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index ea2410981..ffadf0109 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -122,9 +122,10 @@
- +
@@ -182,6 +184,7 @@ import { useRoute } from 'vue-router' import CommentItem from '~/components/CommentItem.vue' import CommentEditor from '~/components/CommentEditor.vue' import BaseTimeline from '~/components/BaseTimeline.vue' +import PostChangeLogItem from '~/components/PostChangeLogItem.vue' import ArticleTags from '~/components/ArticleTags.vue' import ArticleCategory from '~/components/ArticleCategory.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue' @@ -212,6 +215,7 @@ const category = ref('') const tags = ref([]) const postReactions = ref([]) const comments = ref([]) +const changeLogs = ref([]) const status = ref('PUBLISHED') const closed = ref(false) const pinnedAt = ref(null) @@ -225,6 +229,7 @@ const subscribed = ref(false) const commentSort = ref('NEWEST') const isFetchingComments = ref(false) const isMobile = useIsMobile() +const timelineItems = ref([]) const headerHeight = import.meta.client ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 @@ -290,8 +295,13 @@ const gatherPostItems = () => { const main = mainContainer.value.querySelector('.info-content-container') if (main) items.push({ el: main, top: getTop(main) }) - for (const c of comments.value) { - const el = document.getElementById('comment-' + c.id) + for (const c of timelineItems.value) { + let el + if (c.kind === 'comment') { + el = document.getElementById('comment-' + c.id) + } else { + el = document.getElementById('change-log-' + c.id) + } if (el) { items.push({ el, top: getTop(el) }) } @@ -323,12 +333,66 @@ const mapComment = ( ), openReplies: level === 0, src: c.author.avatar, + createdAt: c.createdAt, iconClick: () => navigateTo(`/users/${c.author.id}`), parentUserName: parentUserName, parentUserAvatar: parentUserAvatar, parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null, }) +const changeLogIcon = (l) => { + if (l.type === 'CONTENT') { + return 'edit' + } else if (l.type === 'TITLE') { + return 'hashtag-key' + } else if (l.type === 'CATEGORY') { + return 'tag-one' + } else if (l.type === 'TAG') { + return 'tag-one' + } else if (l.type === 'CLOSED') { + if (l.newClosed) { + return 'lock-one' + } else { + return 'unlock' + } + } else if (l.type === 'PINNED') { + return 'pin-icon' + } else if (l.type === 'FEATURED') { + if (l.newFeatured) { + return 'star' + } else { + return 'dislike' + } + } else if (l.type === 'VOTE_RESULT') { + return 'check-one' + } else if (l.type === 'LOTTERY_RESULT') { + return 'gift' + } else { + return 'info' + } +} + +const mapChangeLog = (l) => ({ + id: l.id, + username: l.username, + userAvatar: l.userAvatar, + type: l.type, + createdAt: l.time, + time: TimeManager.format(l.time), + newClosed: l.newClosed, + newPinnedAt: l.newPinnedAt, + newFeatured: l.newFeatured, + oldContent: l.oldContent, + newContent: l.newContent, + oldTitle: l.oldTitle, + newTitle: l.newTitle, + oldCategory: l.oldCategory, + newCategory: l.newCategory, + oldTags: l.oldTags, + newTags: l.newTags, + icon: changeLogIcon(l), +}) + const getTop = (el) => { return el.getBoundingClientRect().top + window.scrollY } @@ -422,19 +486,21 @@ watchEffect(() => { // router.replace('/404') // } -const totalPosts = computed(() => comments.value.length + 1) +const totalPosts = computed(() => timelineItems.value.length + 1) const lastReplyTime = computed(() => - comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value, + timelineItems.value.length + ? timelineItems.value[timelineItems.value.length - 1].time + : postTime.value, ) const firstReplyTime = computed(() => - comments.value.length ? comments.value[0].time : postTime.value, + timelineItems.value.length ? timelineItems.value[0].time : postTime.value, ) const scrollerTopTime = computed(() => commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value, ) watch( - () => comments.value.length, + () => timelineItems.value.length, async () => { await nextTick() gatherPostItems() @@ -546,6 +612,7 @@ const approvePost = async () => { status.value = 'PUBLISHED' toast.success('已通过审核') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -561,6 +628,7 @@ const pinPost = async () => { if (res.ok) { toast.success('已置顶') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -576,6 +644,7 @@ const unpinPost = async () => { if (res.ok) { toast.success('已取消置顶') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -591,6 +660,7 @@ const excludeRss = async () => { if (res.ok) { rssExcluded.value = true toast.success('已标记为rss不推荐') + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -606,6 +676,7 @@ const includeRss = async () => { if (res.ok) { rssExcluded.value = false toast.success('已标记为rss推荐') + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -622,6 +693,7 @@ const closePost = async () => { closed.value = true toast.success('已关闭') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -638,6 +710,7 @@ const reopenPost = async () => { closed.value = false toast.success('已重新打开') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -682,6 +755,7 @@ const rejectPost = async () => { status.value = 'REJECTED' toast.success('已驳回') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -740,7 +814,42 @@ const fetchComments = async () => { } } -watch(commentSort, fetchComments) +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), + ) + } +} + +watch(commentSort, async () => { + await fetchTimeline() +}) const jumpToHashComment = async () => { const hash = location.hash @@ -763,7 +872,7 @@ const gotoProfile = () => { const initPage = async () => { scrollTo(0, 0) - await fetchComments() + await fetchTimeline() const hash = location.hash const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null if (id) expandCommentPath(id) diff --git a/frontend_nuxt/plugins/iconpark.client.ts b/frontend_nuxt/plugins/iconpark.client.ts index ab87cc6bf..ae19fd5a4 100644 --- a/frontend_nuxt/plugins/iconpark.client.ts +++ b/frontend_nuxt/plugins/iconpark.client.ts @@ -74,6 +74,9 @@ import { Server, Protection, DoubleDown, + Open, + Dislike, + CheckOne, } from '@icon-park/vue-next' export default defineNuxtPlugin((nuxtApp) => { @@ -151,4 +154,7 @@ export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.component('ServerIcon', Server) nuxtApp.vueApp.component('Protection', Protection) nuxtApp.vueApp.component('DoubleDown', DoubleDown) + nuxtApp.vueApp.component('OpenIcon', Open) + nuxtApp.vueApp.component('Dislike', Dislike) + nuxtApp.vueApp.component('CheckOne', CheckOne) })