Merge remote-tracking branch 'origin/main' into feature/env

This commit is contained in:
Tim
2025-08-14 10:34:10 +08:00
14 changed files with 304 additions and 11 deletions

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -38,4 +38,7 @@ public class Comment {
@JoinColumn(name = "parent_id")
private Comment parent;
@Column
private LocalDateTime pinnedAt;
}

View File

@@ -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
}

View File

@@ -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();

View File

@@ -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()));
}
});
}

View File

@@ -0,0 +1 @@
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 || '')