mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 13:30:55 +08:00
Merge remote-tracking branch 'origin/main' into feature/env
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime pinnedAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,4 +38,7 @@ public class Comment {
|
||||
@JoinColumn(name = "parent_id")
|
||||
private Comment parent;
|
||||
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Comment> 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<Comment> pinned = new java.util.ArrayList<>();
|
||||
java.util.List<Comment> 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<Comment> result = new java.util.ArrayList<>();
|
||||
result.addAll(pinned);
|
||||
result.addAll(others);
|
||||
log.debug("getCommentsForPost returning {} comments", result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Comment> 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();
|
||||
|
||||
@@ -69,6 +69,8 @@ public class PostService {
|
||||
private final EmailSender emailSender;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final ConcurrentMap<Long, ScheduledFuture<?>> 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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<span v-if="level >= 2">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||
@@ -74,6 +75,7 @@
|
||||
:comment="item"
|
||||
:level="level + 1"
|
||||
:default-show-replies="item.openReplies"
|
||||
:post-author-id="postAuthorId"
|
||||
/>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
@@ -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;
|
||||
|
||||
@@ -185,6 +185,32 @@
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_WIN'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
恭喜你在抽奖贴
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
中获奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_DRAW'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您的抽奖贴
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
已开奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
@@ -557,6 +583,8 @@ const iconMap = {
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
@@ -614,6 +642,28 @@ const fetchNotifications = async () => {
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
@@ -783,6 +833,10 @@ const formatType = (t) => {
|
||||
return '有人申请注册'
|
||||
case 'ACTIVITY_REDEEM':
|
||||
return '有人申请兑换奶茶'
|
||||
case 'LOTTERY_WIN':
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
return '抽奖已开奖'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
:comment="item"
|
||||
:level="0"
|
||||
:default-show-replies="item.openReplies"
|
||||
:post-author-id="author.id"
|
||||
@deleted="onCommentDeleted"
|
||||
/>
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
Reference in New Issue
Block a user