feat: add user leveling and experience system

This commit is contained in:
Tim
2025-07-28 12:34:45 +08:00
parent a5900aa60d
commit 1c2751422d
18 changed files with 167 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ package com.openisle.controller;
import com.openisle.model.Comment;
import com.openisle.service.CommentService;
import com.openisle.service.CaptchaService;
import com.openisle.service.LevelService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -19,6 +20,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
private final LevelService levelService;
private final CaptchaService captchaService;
@Value("${app.captcha.enabled:false}")
@@ -35,7 +37,9 @@ public class CommentController {
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
return ResponseEntity.ok(toDto(comment));
CommentDto dto = toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
return ResponseEntity.ok(dto);
}
@PostMapping("/comments/{commentId}/replies")
@@ -46,7 +50,9 @@ public class CommentController {
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
return ResponseEntity.ok(toDto(comment));
CommentDto dto = toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
return ResponseEntity.ok(dto);
}
@GetMapping("/posts/{postId}/comments")
@@ -76,6 +82,7 @@ public class CommentController {
dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt());
dto.setAuthor(toAuthorDto(comment.getAuthor()));
dto.setReward(0);
return dto;
}
@@ -100,6 +107,7 @@ public class CommentController {
private LocalDateTime createdAt;
private AuthorDto author;
private List<CommentDto> replies;
private int reward;
}
@Data

View File

@@ -10,6 +10,7 @@ import com.openisle.service.CaptchaService;
import com.openisle.service.DraftService;
import com.openisle.service.SubscriptionService;
import com.openisle.service.UserVisitService;
import com.openisle.service.LevelService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -29,6 +30,7 @@ public class PostController {
private final CommentService commentService;
private final ReactionService reactionService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final DraftService draftService;
private final UserVisitService userVisitService;
@@ -47,7 +49,9 @@ public class PostController {
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
req.getTitle(), req.getContent(), req.getTagIds());
draftService.deleteDraft(auth.getName());
return ResponseEntity.ok(toDto(post));
PostDto dto = toDto(post);
dto.setReward(levelService.awardForPost(auth.getName()));
return ResponseEntity.ok(dto);
}
@PutMapping("/{id}")
@@ -187,6 +191,7 @@ public class PostController {
java.time.LocalDateTime last = commentService.getLastCommentTime(post.getId());
dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
dto.setReward(0);
return dto;
}
@@ -223,6 +228,7 @@ public class PostController {
dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt());
dto.setAuthor(toAuthorDto(comment.getAuthor()));
dto.setReward(0);
return dto;
}
@@ -237,6 +243,7 @@ public class PostController {
if (reaction.getComment() != null) {
dto.setCommentId(reaction.getComment().getId());
}
dto.setReward(0);
return dto;
}
@@ -294,6 +301,7 @@ public class PostController {
private List<ReactionDto> reactions;
private java.util.List<AuthorDto> participants;
private boolean subscribed;
private int reward;
}
@Data
@@ -329,6 +337,7 @@ public class PostController {
private AuthorDto author;
private List<CommentDto> replies;
private List<ReactionDto> reactions;
private int reward;
}
@Data
@@ -338,5 +347,6 @@ public class PostController {
private String user;
private Long postId;
private Long commentId;
private int reward;
}
}

View File

@@ -3,6 +3,7 @@ package com.openisle.controller;
import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.service.ReactionService;
import com.openisle.service.LevelService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class ReactionController {
private final ReactionService reactionService;
private final LevelService levelService;
/**
* Get all available reaction types.
@@ -31,7 +33,9 @@ public class ReactionController {
if (reaction == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(toDto(reaction));
ReactionDto dto = toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
@PostMapping("/comments/{commentId}/reactions")
@@ -42,7 +46,9 @@ public class ReactionController {
if (reaction == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(toDto(reaction));
ReactionDto dto = toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
private ReactionDto toDto(Reaction reaction) {
@@ -56,6 +62,7 @@ public class ReactionController {
if (reaction.getComment() != null) {
dto.setCommentId(reaction.getComment().getId());
}
dto.setReward(0);
return dto;
}
@@ -71,5 +78,6 @@ public class ReactionController {
private String user;
private Long postId;
private Long commentId;
private int reward;
}
}

View File

@@ -27,6 +27,7 @@ public class UserController {
private final SubscriptionService subscriptionService;
private final PostReadService postReadService;
private final UserVisitService userVisitService;
private final LevelService levelService;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
@@ -78,6 +79,12 @@ public class UserController {
return ResponseEntity.ok(toDto(user, auth));
}
@PostMapping("/me/signin")
public Map<String, Integer> signIn(Authentication auth) {
int reward = levelService.awardForSignin(auth.getName());
return Map.of("reward", reward);
}
@GetMapping("/{identifier}")
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
Authentication auth) {
@@ -223,6 +230,9 @@ public class UserController {
dto.setReadPosts(postReadService.countReads(user.getUsername()));
dto.setLikesSent(reactionService.countLikesSent(user.getUsername()));
dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername()));
dto.setExperience(user.getExperience());
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
if (viewer != null) {
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
} else {
@@ -288,6 +298,9 @@ public class UserController {
private long likesSent;
private long likesReceived;
private boolean subscribed;
private int experience;
private int currentLevel;
private int nextLevelExp;
}
@Data

View File

@@ -4,6 +4,7 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
/**
* Reaction entity representing a user's reaction to a post or comment.
@@ -37,4 +38,9 @@ public class Reaction {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
private java.time.LocalDateTime createdAt;
}

View File

@@ -39,6 +39,9 @@ public class User {
private String avatar;
@Column(nullable = false)
private int experience = 0;
@Column(length = 1000)
private String introduction;

View File

@@ -19,4 +19,8 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
@org.springframework.data.jpa.repository.Query("SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post")
java.time.LocalDateTime findLastCommentTime(@org.springframework.data.repository.query.Param("post") Post post);
@org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.author.username = :username AND c.createdAt >= :start")
long countByAuthorAfter(@org.springframework.data.repository.query.Param("username") String username,
@org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start);
}

View File

@@ -87,6 +87,9 @@ public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT SUM(p.views) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED")
Long sumViews(@Param("username") String username);
@Query("SELECT COUNT(p) FROM Post p WHERE p.author.username = :username AND p.createdAt >= :start")
long countByAuthorAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start);
long countByCategory_Id(Long categoryId);
long countDistinctByTags_Id(Long tagId);

View File

@@ -27,6 +27,9 @@ public interface ReactionRepository extends JpaRepository<Reaction, Long> {
@Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.type = com.openisle.model.ReactionType.LIKE")
long countLikesSent(@Param("username") String username);
@Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.createdAt >= :start")
long countByUserAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start);
@Query("SELECT COUNT(r) FROM Reaction r WHERE r.type = com.openisle.model.ReactionType.LIKE AND ((r.post IS NOT NULL AND r.post.author.username = :username) OR (r.comment IS NOT NULL AND r.comment.author.username = :username))")
long countLikesReceived(@Param("username") String username);
}

View File

@@ -0,0 +1,76 @@
package com.openisle.service;
import com.openisle.model.User;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class LevelService {
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final ReactionRepository reactionRepository;
private final UserVisitService userVisitService;
private static final int[] LEVEL_EXP = {100,200,300,600,1200,10000};
private int addExperience(User user, int amount) {
user.setExperience(user.getExperience() + amount);
userRepository.save(user);
return amount;
}
public int awardForPost(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
LocalDateTime start = LocalDate.now().atStartOfDay();
long count = postRepository.countByAuthorAfter(username, start);
if (count >= 1) return 0;
return addExperience(user,30);
}
public int awardForComment(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
LocalDateTime start = LocalDate.now().atStartOfDay();
long count = commentRepository.countByAuthorAfter(username, start);
if (count >= 3) return 0;
return addExperience(user,10);
}
public int awardForReaction(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
LocalDateTime start = LocalDate.now().atStartOfDay();
long count = reactionRepository.countByUserAfter(username, start);
if (count >= 3) return 0;
return addExperience(user,5);
}
public int awardForSignin(String username) {
boolean first = userVisitService.recordVisit(username);
if (!first) return 0;
User user = userRepository.findByUsername(username).orElseThrow();
return addExperience(user,5);
}
public int getLevel(int exp) {
int level = 0;
for (int t : LEVEL_EXP) {
if (exp >= t) level++; else break;
}
return level;
}
public int nextLevelExp(int exp) {
for (int t : LEVEL_EXP) {
if (exp < t) return t;
}
return LEVEL_EXP[LEVEL_EXP.length-1];
}
}

View File

@@ -17,15 +17,16 @@ public class UserVisitService {
private final UserVisitRepository userVisitRepository;
private final UserRepository userRepository;
public void recordVisit(String username) {
public boolean recordVisit(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
LocalDate today = LocalDate.now();
userVisitRepository.findByUserAndVisitDate(user, today).orElseGet(() -> {
return userVisitRepository.findByUserAndVisitDate(user, today).map(v -> false).orElseGet(() -> {
UserVisit visit = new UserVisit();
visit.setUser(user);
visit.setVisitDate(today);
return userVisitRepository.save(visit);
userVisitRepository.save(visit);
return true;
});
}