Compare commits

...

8 Commits

Author SHA1 Message Date
Tim
8544803e62 feat: shorten invite links 2025-09-01 11:25:32 +08:00
Tim
90eee03198 Merge pull request #807 from nagisa77/codex/fix-backend-compilation-issues
test: fix PostServiceTest for new PostService deps
2025-09-01 10:54:07 +08:00
Tim
3f152906f2 test: fix PostServiceTest for new PostService deps 2025-09-01 10:53:50 +08:00
Tim
ef71d0b3d4 Merge pull request #798 from nagisa77/feature/vote
feature for vote
2025-09-01 10:28:44 +08:00
Tim
0852664a82 Merge pull request #802 from sivdead/main
feat(model): 为评论和积分历史实体添加逻辑删除功能
2025-09-01 09:54:07 +08:00
sivdead
b1998be425 Merge remote-tracking branch 'origin/main' 2025-08-31 14:06:18 +08:00
sivdead
72adc5b232 feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:48 +08:00
sivdead
d24e67de5d feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:10 +08:00
10 changed files with 86 additions and 11 deletions

View File

@@ -5,6 +5,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@Table(name = "comments") @Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class Comment { public class Comment {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -41,4 +45,7 @@ public class Comment {
@Column @Column
private LocalDateTime pinnedAt; private LocalDateTime pinnedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
} }

View File

@@ -14,6 +14,13 @@ public class InviteToken {
@Id @Id
private String token; private String token;
/**
* Short token used in invite links. Existing records may have this field null
* and fall back to {@link #token} for backward compatibility.
*/
@Column(unique = true)
private String shortToken;
@ManyToOne @ManyToOne
private User inviter; private User inviter;

View File

@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@Table(name = "point_histories") @Table(name = "point_histories")
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class PointHistory { public class PointHistory {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -46,4 +50,7 @@ public class PointHistory {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
} }

View File

@@ -9,4 +9,8 @@ import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> { public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate); Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
Optional<InviteToken> findByShortToken(String shortToken);
boolean existsByShortToken(String shortToken);
} }

View File

@@ -2,6 +2,7 @@ package com.openisle.repository;
import com.openisle.model.PointHistory; import com.openisle.model.PointHistory;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -12,4 +13,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
long countByUser(User user); long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment);
} }

View File

@@ -11,6 +11,7 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository; import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.NotificationService; import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService; import com.openisle.service.SubscriptionService;
import com.openisle.model.Role; import com.openisle.model.Role;
@@ -37,6 +38,7 @@ public class CommentService {
private final ReactionRepository reactionRepository; private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository; private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final ImageUploader imageUploader; private final ImageUploader imageUploader;
@Transactional @Transactional
@@ -235,10 +237,14 @@ public class CommentService {
for (Comment c : replies) { for (Comment c : replies) {
deleteCommentCascade(c); deleteCommentCascade(c);
} }
// 逻辑删除相关的积分历史记录
pointHistoryRepository.findByComment(comment).forEach(pointHistoryRepository::delete);
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete); reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete); commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment)); notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
commentRepository.delete(comment); commentRepository.delete(comment);
log.debug("deleteCommentCascade removed comment {}", comment.getId()); log.debug("deleteCommentCascade removed comment {}", comment.getId());
} }

View File

@@ -30,33 +30,53 @@ public class InviteService {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today); Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) { if (existing.isPresent()) {
return existing.get().getToken(); InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
} }
String token = jwtService.generateInviteToken(username); String token = jwtService.generateInviteToken(username);
String shortToken;
do {
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} while (inviteTokenRepository.existsByShortToken(shortToken));
InviteToken inviteToken = new InviteToken(); InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token); inviteToken.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter); inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today); inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0); inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken); inviteTokenRepository.save(inviteToken);
return token; return shortToken;
} }
public InviteValidateResult validate(String token) { public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) { if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false); return new InviteValidateResult(null, false);
} }
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
String realToken = token;
if (invite == null) {
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
if (invite == null) {
return new InviteValidateResult(null, false);
}
realToken = invite.getToken();
}
try { try {
jwtService.validateAndGetSubjectForInvite(token); jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) { } catch (Exception e) {
return new InviteValidateResult(null, false); return new InviteValidateResult(null, false);
} }
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3); return new InviteValidateResult(invite, invite.getUsageCount() < 3);
} }
public void consume(String token, String newUserName) { public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow(); InviteToken invite = inviteTokenRepository.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1); invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite); inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName); pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);

View File

@@ -0,0 +1,11 @@
-- Add logical delete support for comments and point_histories tables
-- Add deleted_at column to comments table
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add deleted_at column to point_histories table
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add index for better performance on logical delete queries
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);

View File

@@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository; import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -24,10 +25,11 @@ class CommentServiceTest {
ReactionRepository reactionRepo = mock(ReactionRepository.class); ReactionRepository reactionRepo = mock(ReactionRepository.class);
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class); CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
NotificationRepository nRepo = mock(NotificationRepository.class); NotificationRepository nRepo = mock(NotificationRepository.class);
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
ImageUploader imageUploader = mock(ImageUploader.class); ImageUploader imageUploader = mock(ImageUploader.class);
CommentService service = new CommentService(commentRepo, postRepo, userRepo, CommentService service = new CommentService(commentRepo, postRepo, userRepo,
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader); notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, imageUploader);
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);

View File

@@ -22,6 +22,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class); CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class); TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class); NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class); SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class); CommentService commentService = mock(CommentService.class);
@@ -37,7 +39,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
@@ -69,6 +71,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class); CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class); TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class); NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class); SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class); CommentService commentService = mock(CommentService.class);
@@ -84,7 +88,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
@@ -122,6 +126,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class); CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class); TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class); NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class); SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class); CommentService commentService = mock(CommentService.class);
@@ -137,7 +143,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
@@ -156,6 +162,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class); CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class); TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class); NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class); SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class); CommentService commentService = mock(CommentService.class);
@@ -171,7 +179,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT); imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);