feat: 积分/等级系统
- V10 migration: add score column, back-fill from activity counts, recompute level - IScoreService + ScoreServiceImpl: addScore() atomically updates score and level in DB - UsersRepository.addScore(): JPQL UPDATE with inline CASE level computation - Level tiers: 0新手(<50) 1学徒(50) 2熟手(200) 3达人(500) 4专家(1000) 5大神(2000+) - Score events: - Post discussion: +10 (UserPostController) - Post reply: +5 (UserPostController) - Discussion/post liked: +2 to author; unliked/undone: -2 (LikeCollectServiceImpl) - Score self-like guard: no points awarded when user votes on own content - user-card API: expose level + score fields - user.ftl: replace raw level number with color-coded Lv.N badge + 积分 stat - discussions.ftl + index.ftl hover cards: show level badge and score Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -74,6 +74,9 @@ public class UserPostController extends BaseController {
|
||||
@Autowired
|
||||
private com.yaoyuan.jiscuss.service.INotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private com.yaoyuan.jiscuss.service.IScoreService scoreService;
|
||||
|
||||
|
||||
/**
|
||||
* 首页查看主题列表(各种条件筛选,最热最新标签等)
|
||||
@@ -183,6 +186,10 @@ public class UserPostController extends BaseController {
|
||||
logger.info(">>>{}", saveDiscussion);
|
||||
resultobj.put("msg", "添加主题成功");
|
||||
resultobj.put("flag", true);
|
||||
// Award points for creating a discussion
|
||||
if (user != null) {
|
||||
scoreService.addScore(user.getId(), 10);
|
||||
}
|
||||
return resultobj;
|
||||
}
|
||||
|
||||
@@ -242,6 +249,10 @@ public class UserPostController extends BaseController {
|
||||
logger.info(">>>{}", savePost);
|
||||
resultobj.put("msg", "添加评论成功");
|
||||
resultobj.put("flag", true);
|
||||
// Award points for posting a reply
|
||||
if (user != null) {
|
||||
scoreService.addScore(user.getId(), 5);
|
||||
}
|
||||
return resultobj;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ public class RestMsgController extends BaseController {
|
||||
"id", user.getId(),
|
||||
"username", user.getUsername() != null ? user.getUsername() : "",
|
||||
"discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0,
|
||||
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0
|
||||
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0,
|
||||
"level", user.getLevel() != null ? user.getLevel() : 0,
|
||||
"score", user.getScore() != null ? user.getScore() : 0
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -76,4 +76,7 @@ public class User implements Serializable {
|
||||
|
||||
@Column(name = "level")
|
||||
private Integer level;
|
||||
|
||||
@Column(name = "score")
|
||||
private Integer score;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public class UserInfo implements UserDetails {
|
||||
private String email;
|
||||
private String gender;
|
||||
private Integer level;
|
||||
private Integer score;
|
||||
private Integer flag;
|
||||
|
||||
public UserInfo() {
|
||||
|
||||
@@ -48,4 +48,17 @@ public interface UsersRepository extends JpaRepository<User, Integer> {
|
||||
/** Top active users by comment count (for the ranking sidebar). Shows all users if counts are 0. */
|
||||
@Query("FROM User u ORDER BY COALESCE(u.commentsCount, 0) DESC")
|
||||
List<User> findTop10ActiveUsers(org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE User u SET u.score = GREATEST(0, COALESCE(u.score, 0) + :delta), " +
|
||||
"u.level = CASE " +
|
||||
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 2000 THEN 5 " +
|
||||
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 1000 THEN 4 " +
|
||||
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 500 THEN 3 " +
|
||||
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 200 THEN 2 " +
|
||||
" WHEN (GREATEST(0, COALESCE(u.score, 0) + :delta)) >= 50 THEN 1 " +
|
||||
" ELSE 0 END " +
|
||||
"WHERE u.id = :id")
|
||||
void addScore(@Param("id") Integer id, @Param("delta") int delta);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yaoyuan.jiscuss.service;
|
||||
|
||||
/**
|
||||
* Service for awarding activity points and recomputing user levels.
|
||||
*
|
||||
* <p>Level thresholds (score → level):
|
||||
* <ul>
|
||||
* <li>0 – 49 → 0 新手</li>
|
||||
* <li>50 – 199 → 1 学徒</li>
|
||||
* <li>200 – 499 → 2 熟手</li>
|
||||
* <li>500 – 999 → 3 达人</li>
|
||||
* <li>1000 – 1999 → 4 专家</li>
|
||||
* <li>2000+ → 5 大神</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface IScoreService {
|
||||
|
||||
/** Award (or deduct) {@code delta} points to the user and refresh their level. */
|
||||
void addScore(Integer userId, int delta);
|
||||
|
||||
/** Compute the level (0-5) corresponding to a given score. */
|
||||
static int computeLevel(int score) {
|
||||
if (score >= 2000) return 5;
|
||||
if (score >= 1000) return 4;
|
||||
if (score >= 500) return 3;
|
||||
if (score >= 200) return 2;
|
||||
if (score >= 50) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Human-readable name for a level (0-5). */
|
||||
static String levelName(int level) {
|
||||
return switch (level) {
|
||||
case 1 -> "学徒";
|
||||
case 2 -> "熟手";
|
||||
case 3 -> "达人";
|
||||
case 4 -> "专家";
|
||||
case 5 -> "大神";
|
||||
default -> "新手";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.yaoyuan.jiscuss.repository.LikeCollectRepository;
|
||||
import com.yaoyuan.jiscuss.repository.PostsRepository;
|
||||
import com.yaoyuan.jiscuss.service.ILikeCollectService;
|
||||
import com.yaoyuan.jiscuss.service.INotificationService;
|
||||
import com.yaoyuan.jiscuss.entity.Discussion;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -29,9 +30,19 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
@Autowired
|
||||
private INotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private com.yaoyuan.jiscuss.service.IScoreService scoreService;
|
||||
|
||||
@Autowired
|
||||
private com.yaoyuan.jiscuss.repository.UsersRepository usersRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public String voteDiscussion(Integer userId, Integer discussionId, String action) {
|
||||
// Look up discussion author once for score updates
|
||||
com.yaoyuan.jiscuss.entity.Discussion disc = discussionsRepository.findById(discussionId).orElse(null);
|
||||
Integer authorId = (disc != null) ? disc.getStartUserId() : null;
|
||||
|
||||
Optional<LikeCollect> existing = likeCollectRepository.findDiscussionVote(userId, discussionId);
|
||||
if (existing.isPresent()) {
|
||||
LikeCollect vote = existing.get();
|
||||
@@ -39,6 +50,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
// Toggle off: cancel the same action
|
||||
likeCollectRepository.delete(vote);
|
||||
adjustDiscussion(discussionId, action, -1);
|
||||
// Reverse score for the author (only likes affect score)
|
||||
if ("like".equals(action) && authorId != null && !authorId.equals(userId)) {
|
||||
scoreService.addScore(authorId, -2);
|
||||
}
|
||||
return "none";
|
||||
} else {
|
||||
// Switch action: undo old, apply new
|
||||
@@ -46,6 +61,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
vote.setAction(action);
|
||||
likeCollectRepository.save(vote);
|
||||
adjustDiscussion(discussionId, action, +1);
|
||||
// Switched from like→dislike: deduct; dislike→like: award
|
||||
if (authorId != null && !authorId.equals(userId)) {
|
||||
if ("like".equals(action)) {
|
||||
scoreService.addScore(authorId, +2);
|
||||
} else {
|
||||
scoreService.addScore(authorId, -2);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}
|
||||
} else {
|
||||
@@ -59,6 +82,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
vote.setCreateTime(new Date());
|
||||
likeCollectRepository.save(vote);
|
||||
adjustDiscussion(discussionId, action, +1);
|
||||
// Award score to discussion author on new like
|
||||
if ("like".equals(action) && authorId != null && !authorId.equals(userId)) {
|
||||
scoreService.addScore(authorId, +2);
|
||||
}
|
||||
return action;
|
||||
}
|
||||
}
|
||||
@@ -72,12 +99,28 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
if (vote.getAction().equals(action)) {
|
||||
likeCollectRepository.delete(vote);
|
||||
adjustPost(postId, action, -1);
|
||||
// Reverse score on unlike
|
||||
if ("like".equals(action)) {
|
||||
Post post = postsRepository.findById(postId).orElse(null);
|
||||
if (post != null && post.getCreateId() != null && !post.getCreateId().equals(userId)) {
|
||||
scoreService.addScore(post.getCreateId(), -2);
|
||||
}
|
||||
}
|
||||
return "none";
|
||||
} else {
|
||||
adjustPost(postId, vote.getAction(), -1);
|
||||
vote.setAction(action);
|
||||
likeCollectRepository.save(vote);
|
||||
adjustPost(postId, action, +1);
|
||||
// Switched vote direction: adjust score accordingly
|
||||
Post post = postsRepository.findById(postId).orElse(null);
|
||||
if (post != null && post.getCreateId() != null && !post.getCreateId().equals(userId)) {
|
||||
if ("like".equals(action)) {
|
||||
scoreService.addScore(post.getCreateId(), +2);
|
||||
} else {
|
||||
scoreService.addScore(post.getCreateId(), -2);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}
|
||||
} else {
|
||||
@@ -90,11 +133,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
|
||||
vote.setCreateTime(new Date());
|
||||
likeCollectRepository.save(vote);
|
||||
adjustPost(postId, action, +1);
|
||||
// Notify post author when liked (not disliked)
|
||||
// Award score to post author on new like; also send notification
|
||||
if ("like".equals(action)) {
|
||||
Post post = postsRepository.findById(postId).orElse(null);
|
||||
if (post != null && post.getCreateId() != null) {
|
||||
notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId());
|
||||
if (!post.getCreateId().equals(userId)) {
|
||||
scoreService.addScore(post.getCreateId(), +2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return action;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yaoyuan.jiscuss.service.impl;
|
||||
|
||||
import com.yaoyuan.jiscuss.repository.UsersRepository;
|
||||
import com.yaoyuan.jiscuss.service.IScoreService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class ScoreServiceImpl implements IScoreService {
|
||||
|
||||
@Autowired
|
||||
private UsersRepository usersRepository;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@CacheEvict(value = "user", key = "#userId")
|
||||
public void addScore(Integer userId, int delta) {
|
||||
if (userId == null || delta == 0) return;
|
||||
usersRepository.addScore(userId, delta);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user