feat: implement lottery post type

This commit is contained in:
Tim
2025-08-11 01:17:55 +08:00
parent 4441c697b3
commit eb32e4bad7
11 changed files with 221 additions and 7 deletions

View File

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

View File

@@ -39,7 +39,9 @@ public class PostController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
Post post = postService.createPost(auth.getName(), req.getCategoryId(), 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()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName())); dto.setReward(levelService.awardForPost(auth.getName()));
@@ -67,6 +69,12 @@ public class PostController {
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer)); return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
} }
@PostMapping("/{id}/lottery/join")
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
postService.joinLottery(id, auth.getName());
return ResponseEntity.ok().build();
}
@GetMapping @GetMapping
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds, @RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,

View File

@@ -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<AuthorDto> participants;
private List<AuthorDto> winners;
}

View File

@@ -2,8 +2,11 @@ package com.openisle.dto;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import com.openisle.model.PostType;
/** /**
* Request body for creating or updating a post. * Request body for creating or updating a post.
*/ */
@@ -14,5 +17,13 @@ public class PostRequest {
private String content; private String content;
private List<Long> tagIds; private List<Long> tagIds;
private String captcha; 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;
} }

View File

@@ -1,6 +1,7 @@
package com.openisle.dto; package com.openisle.dto;
import com.openisle.model.PostStatus; import com.openisle.model.PostStatus;
import com.openisle.model.PostType;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -28,5 +29,7 @@ public class PostSummaryDto {
private boolean subscribed; private boolean subscribed;
private int reward; private int reward;
private int pointReward; private int pointReward;
private PostType type;
private LotteryDto lottery;
} }

View File

@@ -4,8 +4,10 @@ import com.openisle.dto.CommentDto;
import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto; import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ReactionDto; import com.openisle.dto.ReactionDto;
import com.openisle.dto.LotteryDto;
import com.openisle.model.CommentSort; import com.openisle.model.CommentSort;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.LotteryPost;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.service.CommentService; import com.openisle.service.CommentService;
import com.openisle.service.ReactionService; import com.openisle.service.ReactionService;
@@ -75,5 +77,18 @@ public class PostMapper {
dto.setLastReplyAt(last != null ? last : post.getCreatedAt()); dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
dto.setReward(0); dto.setReward(0);
dto.setSubscribed(false); 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);
}
} }
} }

View File

@@ -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<User> participants = new HashSet<>();
@ManyToMany
@JoinTable(name = "lottery_winners",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> winners = new HashSet<>();
}

View File

@@ -9,11 +9,11 @@ import org.hibernate.annotations.CreationTimestamp;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.time.LocalDateTime;
import com.openisle.model.Tag; import com.openisle.model.Tag;
import java.time.LocalDateTime;
/** /**
* Post entity representing an article posted by a user. * Post entity representing an article posted by a user.
*/ */
@@ -22,6 +22,7 @@ import java.time.LocalDateTime;
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@Table(name = "posts") @Table(name = "posts")
@Inheritance(strategy = InheritanceType.JOINED)
public class Post { public class Post {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -59,6 +60,10 @@ public class Post {
@Column(nullable = false) @Column(nullable = false)
private PostStatus status = PostStatus.PUBLISHED; private PostStatus status = PostStatus.PUBLISHED;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostType type = PostType.NORMAL;
@Column @Column
private LocalDateTime pinnedAt; private LocalDateTime pinnedAt;

View File

@@ -0,0 +1,6 @@
package com.openisle.model;
public enum PostType {
NORMAL,
LOTTERY
}

View File

@@ -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<LotteryPost, Long> {
}

View File

@@ -2,12 +2,15 @@ package com.openisle.service;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.PostStatus; import com.openisle.model.PostStatus;
import com.openisle.model.PostType;
import com.openisle.model.PublishMode; import com.openisle.model.PublishMode;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.Category; import com.openisle.model.Category;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import com.openisle.model.LotteryPost;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository; import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository; import com.openisle.repository.TagRepository;
@@ -21,6 +24,8 @@ import com.openisle.model.Role;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.scheduling.TaskScheduler;
import com.openisle.service.EmailSender;
import java.util.List; import java.util.List;
import org.springframework.data.domain.PageRequest; 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.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional; 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 @Service
public class PostService { public class PostService {
private final PostRepository postRepository; private final PostRepository postRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private PublishMode publishMode; private PublishMode publishMode;
private final NotificationService notificationService; private final NotificationService notificationService;
private final SubscriptionService subscriptionService; private final SubscriptionService subscriptionService;
@@ -44,12 +56,15 @@ public class PostService {
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final PostReadService postReadService; private final PostReadService postReadService;
private final ImageUploader imageUploader; private final ImageUploader imageUploader;
private final TaskScheduler taskScheduler;
private final EmailSender emailSender;
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository, public PostService(PostRepository postRepository,
UserRepository userRepository, UserRepository userRepository,
CategoryRepository categoryRepository, CategoryRepository categoryRepository,
TagRepository tagRepository, TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
NotificationService notificationService, NotificationService notificationService,
SubscriptionService subscriptionService, SubscriptionService subscriptionService,
CommentService commentService, CommentService commentService,
@@ -59,11 +74,14 @@ public class PostService {
NotificationRepository notificationRepository, NotificationRepository notificationRepository,
PostReadService postReadService, PostReadService postReadService,
ImageUploader imageUploader, ImageUploader imageUploader,
TaskScheduler taskScheduler,
EmailSender emailSender,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.notificationService = notificationService; this.notificationService = notificationService;
this.subscriptionService = subscriptionService; this.subscriptionService = subscriptionService;
this.commentService = commentService; this.commentService = commentService;
@@ -73,6 +91,8 @@ public class PostService {
this.notificationRepository = notificationRepository; this.notificationRepository = notificationRepository;
this.postReadService = postReadService; this.postReadService = postReadService;
this.imageUploader = imageUploader; this.imageUploader = imageUploader;
this.taskScheduler = taskScheduler;
this.emailSender = emailSender;
this.publishMode = publishMode; this.publishMode = publishMode;
} }
@@ -88,7 +108,13 @@ public class PostService {
Long categoryId, Long categoryId,
String title, String title,
String content, String content,
java.util.List<Long> tagIds) { java.util.List<Long> tagIds,
PostType type,
String prizeDescription,
String prizeIcon,
Integer prizeCount,
LocalDateTime startTime,
LocalDateTime endTime) {
long recent = postRepository.countByAuthorAfter(username, long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5)); java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) { if (recent >= 1) {
@@ -108,14 +134,31 @@ public class PostService {
if (tags.isEmpty()) { if (tags.isEmpty()) {
throw new IllegalArgumentException("Tag not found"); 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.setTitle(title);
post.setContent(content); post.setContent(content);
post.setAuthor(author); post.setAuthor(author);
post.setCategory(category); 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.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)); imageUploader.addReferences(imageUploader.extractUrls(content));
if (post.getStatus() == PostStatus.PENDING) { if (post.getStatus() == PostStatus.PENDING) {
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
@@ -141,9 +184,42 @@ public class PostService {
} }
} }
notificationService.notifyMentions(content, author, post, null); 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; 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<User> participants = new ArrayList<>(lp.getParticipants());
if (participants.isEmpty()) {
return;
}
Collections.shuffle(participants);
int winnersCount = Math.min(lp.getPrizeCount(), participants.size());
java.util.Set<User> 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 @Transactional
public Post viewPost(Long id, String viewer) { public Post viewPost(Long id, String viewer) {
Post post = postRepository.findById(id) Post post = postRepository.findById(id)