diff --git a/backend/src/main/java/com/openisle/controller/AdminCommentController.java b/backend/src/main/java/com/openisle/controller/AdminCommentController.java new file mode 100644 index 000000000..850b81784 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/AdminCommentController.java @@ -0,0 +1,29 @@ +package com.openisle.controller; + +import com.openisle.dto.CommentDto; +import com.openisle.mapper.CommentMapper; +import com.openisle.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * Endpoints for administrators to manage comments. + */ +@RestController +@RequestMapping("/api/admin/comments") +@RequiredArgsConstructor +public class AdminCommentController { + private final CommentService commentService; + private final CommentMapper commentMapper; + + @PostMapping("/{id}/pin") + public CommentDto pin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } + + @PostMapping("/{id}/unpin") + public CommentDto unpin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } +} diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index 01669ea61..09e998607 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -85,4 +85,16 @@ public class CommentController { commentService.deleteComment(auth.getName(), id); log.debug("deleteComment completed for comment {}", id); } + + @PostMapping("/comments/{id}/pin") + public CommentDto pinComment(@PathVariable Long id, Authentication auth) { + log.debug("pinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } + + @PostMapping("/comments/{id}/unpin") + public CommentDto unpinComment(@PathVariable Long id, Authentication auth) { + log.debug("unpinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } } diff --git a/backend/src/main/java/com/openisle/dto/CommentDto.java b/backend/src/main/java/com/openisle/dto/CommentDto.java index 54c3711f0..4442d35ca 100644 --- a/backend/src/main/java/com/openisle/dto/CommentDto.java +++ b/backend/src/main/java/com/openisle/dto/CommentDto.java @@ -13,6 +13,7 @@ public class CommentDto { private Long id; private String content; private LocalDateTime createdAt; + private LocalDateTime pinnedAt; private AuthorDto author; private List replies; private List reactions; diff --git a/backend/src/main/java/com/openisle/mapper/CommentMapper.java b/backend/src/main/java/com/openisle/mapper/CommentMapper.java index d9b065b45..a83fb44c3 100644 --- a/backend/src/main/java/com/openisle/mapper/CommentMapper.java +++ b/backend/src/main/java/com/openisle/mapper/CommentMapper.java @@ -24,6 +24,7 @@ public class CommentMapper { dto.setId(comment.getId()); dto.setContent(comment.getContent()); dto.setCreatedAt(comment.getCreatedAt()); + dto.setPinnedAt(comment.getPinnedAt()); dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); dto.setReward(0); return dto; diff --git a/backend/src/main/java/com/openisle/model/Comment.java b/backend/src/main/java/com/openisle/model/Comment.java index 5a1bdb5c0..d613f13da 100644 --- a/backend/src/main/java/com/openisle/model/Comment.java +++ b/backend/src/main/java/com/openisle/model/Comment.java @@ -38,4 +38,7 @@ public class Comment { @JoinColumn(name = "parent_id") private Comment parent; + @Column + private LocalDateTime pinnedAt; + } diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 8679530ff..d9735bc29 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -32,6 +32,10 @@ public enum NotificationType { REGISTER_REQUEST, /** A user redeemed an activity reward */ ACTIVITY_REDEEM, + /** You won a lottery post */ + LOTTERY_WIN, + /** Your lottery post was drawn */ + LOTTERY_DRAW, /** You were mentioned in a post or comment */ MENTION } diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index 95dd7b9d7..3ac994e30 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -23,6 +23,7 @@ import java.util.List; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; @Service @RequiredArgsConstructor @@ -129,13 +130,26 @@ public class CommentService { Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); List list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); - if (sort == CommentSort.NEWEST) { - list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); - } else if (sort == CommentSort.MOST_INTERACTIONS) { - list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); + java.util.List pinned = new java.util.ArrayList<>(); + java.util.List others = new java.util.ArrayList<>(); + for (Comment c : list) { + if (c.getPinnedAt() != null) { + pinned.add(c); + } else { + others.add(c); + } } - log.debug("getCommentsForPost returning {} comments", list.size()); - return list; + pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed()); + if (sort == CommentSort.NEWEST) { + others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); + } else if (sort == CommentSort.MOST_INTERACTIONS) { + others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); + } + java.util.List result = new java.util.ArrayList<>(); + result.addAll(pinned); + result.addAll(others); + log.debug("getCommentsForPost returning {} comments", result.size()); + return result; } public List getReplies(Long parentId) { @@ -223,6 +237,32 @@ public class CommentService { log.debug("deleteCommentCascade removed comment {}", comment.getId()); } + @Transactional + public Comment pinComment(String username, Long id) { + Comment c = commentRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + c.setPinnedAt(LocalDateTime.now()); + return commentRepository.save(c); + } + + @Transactional + public Comment unpinComment(String username, Long id) { + Comment c = commentRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + c.setPinnedAt(null); + return commentRepository.save(c); + } + private int interactionCount(Comment comment) { int reactions = reactionRepository.findByComment(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 14f94d968..7b2558e43 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -69,6 +69,8 @@ public class PostService { private final EmailSender emailSender; private final ApplicationContext applicationContext; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); + @Value("${app.website-url:https://www.open-isle.com}") + private String websiteUrl; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, @@ -249,6 +251,15 @@ public class PostService { if (w.getEmail() != null) { emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); } + notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null); + notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId())); + } + if (lp.getAuthor() != null) { + if (lp.getAuthor().getEmail() != null) { + emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"); + } + 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())); } }); } diff --git a/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql b/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql new file mode 100644 index 000000000..c5c5b6720 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql @@ -0,0 +1 @@ +ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL; diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 315fd0fb2..e1dbfd297 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -93,4 +93,50 @@ class PostServiceTest { () -> service.createPost("alice", 1L, "t", "c", List.of(1L), null, null, null, null, null, null)); } + + @Test + void finalizeLotteryNotifiesAuthor() { + 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); + 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); + + PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, + notifService, subService, commentService, commentRepo, + reactionRepo, subRepo, notificationRepo, postReadService, + imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT); + when(context.getBean(PostService.class)).thenReturn(service); + + User author = new User(); + author.setId(1L); + User winner = new User(); + winner.setId(2L); + + LotteryPost lp = new LotteryPost(); + lp.setId(1L); + lp.setAuthor(author); + lp.setTitle("L"); + lp.setPrizeCount(1); + lp.getParticipants().add(winner); + + when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp)); + + service.finalizeLottery(1L); + + verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull()); + verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull()); + } } diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue index 10836a393..e648a38e4 100644 --- a/frontend_nuxt/components/CommentItem.vue +++ b/frontend_nuxt/components/CommentItem.vue @@ -22,6 +22,7 @@ :to="`/users/${comment.userId}?tab=achievements`" >{{ getMedalTitle(comment.medal) }} + {{ comment.parentUserName }} @@ -74,6 +75,7 @@ :comment="item" :level="level + 1" :default-show-replies="item.openReplies" + :post-author-id="postAuthorId" /> @@ -116,6 +118,10 @@ const props = defineProps({ type: Boolean, default: false, }, + postAuthorId: { + type: [Number, String], + required: true, + }, }) const emit = defineEmits(['deleted']) @@ -170,12 +176,22 @@ const replyList = computed(() => { }) const isAuthor = computed(() => authState.username === props.comment.userName) +const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId)) const isAdmin = computed(() => authState.role === 'ADMIN') -const commentMenuItems = computed(() => - isAuthor.value || isAdmin.value - ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] - : [], -) +const commentMenuItems = computed(() => { + const items = [] + if (isAuthor.value || isAdmin.value) { + items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() }) + } + if (isAdmin.value || isPostAuthor.value) { + if (props.comment.pinned) { + items.push({ text: '取消置顶', onClick: () => unpinComment() }) + } else { + items.push({ text: '置顶', onClick: () => pinComment() }) + } + } + return items +}) const deleteComment = async () => { const token = getToken() if (!token) { @@ -257,6 +273,47 @@ const submitReply = async (parentUserName, text, clear) => { } } +const pinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/pin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = true + toast.success('已置顶') + } else { + toast.error('操作失败') + } +} +const unpinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/unpin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = false + toast.success('已取消置顶') + } else { + toast.error('操作失败') + } +} + const copyCommentLink = () => { const link = `${location.origin}${location.pathname}#comment-${props.comment.id}` navigator.clipboard.writeText(link).then(() => { @@ -336,6 +393,12 @@ const handleContentClick = (e) => { margin-left: 10px; } +.pin-icon { + font-size: 12px; + margin-left: 10px; + opacity: 0.6; +} + @keyframes highlight { from { background-color: yellow; diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 2f0f50849..831c3238f 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -185,6 +185,32 @@ + + @@ -391,6 +392,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({ avatar: c.author.avatar, text: c.content, reactions: c.reactions || [], + pinned: !!c.pinnedAt, reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), openReplies: level === 0, src: c.author.avatar, diff --git a/frontend_nuxt/utils/markdown.js b/frontend_nuxt/utils/markdown.js index 829c95988..27a1f8831 100644 --- a/frontend_nuxt/utils/markdown.js +++ b/frontend_nuxt/utils/markdown.js @@ -50,6 +50,31 @@ function tiebaEmojiPlugin(md) { }) } +// 链接在新窗口打开 +function linkPlugin(md) { + const defaultRender = + md.renderer.rules.link_open || + function (tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options) + } + + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const token = tokens[idx] + const hrefIndex = token.attrIndex('href') + + if (hrefIndex >= 0) { + const href = token.attrs[hrefIndex][1] + // 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer" + if (href.startsWith('http://') || href.startsWith('https://')) { + token.attrPush(['target', '_blank']) + token.attrPush(['rel', 'noopener noreferrer']) + } + } + + return defaultRender(tokens, idx, options, env, self) + } +} + const md = new MarkdownIt({ html: false, linkify: true, @@ -67,6 +92,7 @@ const md = new MarkdownIt({ md.use(mentionPlugin) md.use(tiebaEmojiPlugin) +md.use(linkPlugin) // 添加链接插件 export function renderMarkdown(text) { return md.render(text || '')