diff --git a/backend/src/main/java/com/openisle/config/SchedulerConfig.java b/backend/src/main/java/com/openisle/config/SchedulerConfig.java new file mode 100644 index 000000000..c087ccd6b --- /dev/null +++ b/backend/src/main/java/com/openisle/config/SchedulerConfig.java @@ -0,0 +1,20 @@ +package com.openisle.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.TaskScheduler; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + scheduler.setThreadNamePrefix("lottery-"); + scheduler.initialize(); + return scheduler; + } +} diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 52932b53a..736cba857 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -39,7 +39,9 @@ public class PostController { return ResponseEntity.badRequest().build(); } Post post = postService.createPost(auth.getName(), req.getCategoryId(), - req.getTitle(), req.getContent(), req.getTagIds()); + req.getTitle(), req.getContent(), req.getTagIds(), + req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), + req.getPrizeCount(), req.getStartTime(), req.getEndTime()); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); @@ -67,6 +69,12 @@ public class PostController { return ResponseEntity.ok(postMapper.toDetailDto(post, viewer)); } + @PostMapping("/{id}/lottery/join") + public ResponseEntity joinLottery(@PathVariable Long id, Authentication auth) { + postService.joinLottery(id, auth.getName()); + return ResponseEntity.ok().build(); + } + @GetMapping public List listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryIds", required = false) List categoryIds, diff --git a/backend/src/main/java/com/openisle/dto/LotteryDto.java b/backend/src/main/java/com/openisle/dto/LotteryDto.java new file mode 100644 index 000000000..db6728993 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/LotteryDto.java @@ -0,0 +1,17 @@ +package com.openisle.dto; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +/** Metadata for lottery posts. */ +@Data +public class LotteryDto { + private String prizeDescription; + private String prizeIcon; + private int prizeCount; + private LocalDateTime startTime; + private LocalDateTime endTime; + private List participants; + private List winners; +} diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 0c40537e1..014d68bf7 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -2,8 +2,11 @@ package com.openisle.dto; import lombok.Data; +import java.time.LocalDateTime; import java.util.List; +import com.openisle.model.PostType; + /** * Request body for creating or updating a post. */ @@ -14,5 +17,13 @@ public class PostRequest { private String content; private List tagIds; private String captcha; + + // optional for lottery posts + private PostType type; + private String prizeDescription; + private String prizeIcon; + private Integer prizeCount; + private LocalDateTime startTime; + private LocalDateTime endTime; } diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index f77e1df59..d849c225b 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -1,6 +1,7 @@ package com.openisle.dto; import com.openisle.model.PostStatus; +import com.openisle.model.PostType; import lombok.Data; import java.time.LocalDateTime; @@ -28,5 +29,7 @@ public class PostSummaryDto { private boolean subscribed; private int reward; private int pointReward; + private PostType type; + private LotteryDto lottery; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 12eba57fb..58652f17b 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -4,8 +4,10 @@ import com.openisle.dto.CommentDto; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostSummaryDto; import com.openisle.dto.ReactionDto; +import com.openisle.dto.LotteryDto; import com.openisle.model.CommentSort; import com.openisle.model.Post; +import com.openisle.model.LotteryPost; import com.openisle.model.User; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; @@ -75,5 +77,18 @@ public class PostMapper { dto.setLastReplyAt(last != null ? last : post.getCreatedAt()); dto.setReward(0); dto.setSubscribed(false); + dto.setType(post.getType()); + + if (post instanceof LotteryPost lp) { + LotteryDto l = new LotteryDto(); + l.setPrizeDescription(lp.getPrizeDescription()); + l.setPrizeIcon(lp.getPrizeIcon()); + l.setPrizeCount(lp.getPrizeCount()); + l.setStartTime(lp.getStartTime()); + l.setEndTime(lp.getEndTime()); + l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + dto.setLottery(l); + } } } diff --git a/backend/src/main/java/com/openisle/model/LotteryPost.java b/backend/src/main/java/com/openisle/model/LotteryPost.java new file mode 100644 index 000000000..79639a1da --- /dev/null +++ b/backend/src/main/java/com/openisle/model/LotteryPost.java @@ -0,0 +1,46 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "lottery_posts") +@Getter +@Setter +@NoArgsConstructor +@PrimaryKeyJoinColumn(name = "post_id") +public class LotteryPost extends Post { + + @Column + private String prizeDescription; + + @Column + private String prizeIcon; + + @Column(nullable = false) + private int prizeCount; + + @Column + private LocalDateTime startTime; + + @Column + private LocalDateTime endTime; + + @ManyToMany + @JoinTable(name = "lottery_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set participants = new HashSet<>(); + + @ManyToMany + @JoinTable(name = "lottery_winners", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set winners = new HashSet<>(); +} diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 0f7a752e1..0f5a709f1 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -9,11 +9,11 @@ import org.hibernate.annotations.CreationTimestamp; import java.util.HashSet; import java.util.Set; +import java.time.LocalDateTime; + import com.openisle.model.Tag; -import java.time.LocalDateTime; - /** * Post entity representing an article posted by a user. */ @@ -22,6 +22,7 @@ import java.time.LocalDateTime; @Setter @NoArgsConstructor @Table(name = "posts") +@Inheritance(strategy = InheritanceType.JOINED) public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -59,6 +60,10 @@ public class Post { @Column(nullable = false) private PostStatus status = PostStatus.PUBLISHED; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostType type = PostType.NORMAL; + @Column private LocalDateTime pinnedAt; diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java new file mode 100644 index 000000000..d14a28701 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -0,0 +1,6 @@ +package com.openisle.model; + +public enum PostType { + NORMAL, + LOTTERY +} diff --git a/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java new file mode 100644 index 000000000..f06350329 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java @@ -0,0 +1,7 @@ +package com.openisle.repository; + +import com.openisle.model.LotteryPost; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LotteryPostRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index c3293c4c9..a694e06c4 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -2,12 +2,15 @@ package com.openisle.service; import com.openisle.model.Post; import com.openisle.model.PostStatus; +import com.openisle.model.PostType; import com.openisle.model.PublishMode; import com.openisle.model.User; import com.openisle.model.Category; import com.openisle.model.Comment; import com.openisle.model.NotificationType; +import com.openisle.model.LotteryPost; import com.openisle.repository.PostRepository; +import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; @@ -21,6 +24,8 @@ import com.openisle.model.Role; import com.openisle.exception.RateLimitException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.scheduling.TaskScheduler; +import com.openisle.service.EmailSender; import java.util.List; import org.springframework.data.domain.PageRequest; @@ -28,12 +33,19 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; +import java.time.ZoneId; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; + @Service public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; + private final LotteryPostRepository lotteryPostRepository; private PublishMode publishMode; private final NotificationService notificationService; private final SubscriptionService subscriptionService; @@ -44,12 +56,15 @@ public class PostService { private final NotificationRepository notificationRepository; private final PostReadService postReadService; private final ImageUploader imageUploader; + private final TaskScheduler taskScheduler; + private final EmailSender emailSender; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, UserRepository userRepository, CategoryRepository categoryRepository, TagRepository tagRepository, + LotteryPostRepository lotteryPostRepository, NotificationService notificationService, SubscriptionService subscriptionService, CommentService commentService, @@ -59,11 +74,14 @@ public class PostService { NotificationRepository notificationRepository, PostReadService postReadService, ImageUploader imageUploader, + TaskScheduler taskScheduler, + EmailSender emailSender, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; + this.lotteryPostRepository = lotteryPostRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; this.commentService = commentService; @@ -73,6 +91,8 @@ public class PostService { this.notificationRepository = notificationRepository; this.postReadService = postReadService; this.imageUploader = imageUploader; + this.taskScheduler = taskScheduler; + this.emailSender = emailSender; this.publishMode = publishMode; } @@ -88,7 +108,13 @@ public class PostService { Long categoryId, String title, String content, - java.util.List tagIds) { + java.util.List tagIds, + PostType type, + String prizeDescription, + String prizeIcon, + Integer prizeCount, + LocalDateTime startTime, + LocalDateTime endTime) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { @@ -108,14 +134,31 @@ public class PostService { if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } - Post post = new Post(); + PostType actualType = type != null ? type : PostType.NORMAL; + Post post; + if (actualType == PostType.LOTTERY) { + LotteryPost lp = new LotteryPost(); + lp.setPrizeDescription(prizeDescription); + lp.setPrizeIcon(prizeIcon); + lp.setPrizeCount(prizeCount != null ? prizeCount : 0); + lp.setStartTime(startTime); + lp.setEndTime(endTime); + post = lp; + } else { + post = new Post(); + } + post.setType(actualType); post.setTitle(title); post.setContent(content); post.setAuthor(author); post.setCategory(category); - post.setTags(new java.util.HashSet<>(tags)); + post.setTags(new HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); - post = postRepository.save(post); + if (post instanceof LotteryPost) { + post = lotteryPostRepository.save((LotteryPost) post); + } else { + post = postRepository.save(post); + } imageUploader.addReferences(imageUploader.extractUrls(content)); if (post.getStatus() == PostStatus.PENDING) { java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); @@ -141,9 +184,42 @@ public class PostService { } } notificationService.notifyMentions(content, author, post, null); + + if (post instanceof LotteryPost lp && lp.getEndTime() != null) { + taskScheduler.schedule(() -> finalizeLottery(lp.getId()), + java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + } return post; } + public void joinLottery(Long postId, String username) { + LotteryPost post = lotteryPostRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + post.getParticipants().add(user); + lotteryPostRepository.save(post); + } + + private void finalizeLottery(Long postId) { + lotteryPostRepository.findById(postId).ifPresent(lp -> { + List participants = new ArrayList<>(lp.getParticipants()); + if (participants.isEmpty()) { + return; + } + Collections.shuffle(participants); + int winnersCount = Math.min(lp.getPrizeCount(), participants.size()); + java.util.Set winners = new java.util.HashSet<>(participants.subList(0, winnersCount)); + lp.setWinners(winners); + lotteryPostRepository.save(lp); + for (User w : winners) { + if (w.getEmail() != null) { + emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); + } + } + }); + } + @Transactional public Post viewPost(Long id, String viewer) { Post post = postRepository.findById(id)