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:
2026-05-13 18:30:52 +08:00
parent aeb0866ec1
commit ab9749a111
12 changed files with 191 additions and 8 deletions
@@ -74,6 +74,9 @@ public class UserPostController extends BaseController {
@Autowired @Autowired
private com.yaoyuan.jiscuss.service.INotificationService notificationService; 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); logger.info(">>>{}", saveDiscussion);
resultobj.put("msg", "添加主题成功"); resultobj.put("msg", "添加主题成功");
resultobj.put("flag", true); resultobj.put("flag", true);
// Award points for creating a discussion
if (user != null) {
scoreService.addScore(user.getId(), 10);
}
return resultobj; return resultobj;
} }
@@ -242,6 +249,10 @@ public class UserPostController extends BaseController {
logger.info(">>>{}", savePost); logger.info(">>>{}", savePost);
resultobj.put("msg", "添加评论成功"); resultobj.put("msg", "添加评论成功");
resultobj.put("flag", true); resultobj.put("flag", true);
// Award points for posting a reply
if (user != null) {
scoreService.addScore(user.getId(), 5);
}
return resultobj; return resultobj;
} }
@@ -49,7 +49,9 @@ public class RestMsgController extends BaseController {
"id", user.getId(), "id", user.getId(),
"username", user.getUsername() != null ? user.getUsername() : "", "username", user.getUsername() != null ? user.getUsername() : "",
"discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0, "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") @Column(name = "level")
private Integer level; private Integer level;
@Column(name = "score")
private Integer score;
} }
@@ -26,6 +26,7 @@ public class UserInfo implements UserDetails {
private String email; private String email;
private String gender; private String gender;
private Integer level; private Integer level;
private Integer score;
private Integer flag; private Integer flag;
public UserInfo() { 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. */ /** 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") @Query("FROM User u ORDER BY COALESCE(u.commentsCount, 0) DESC")
List<User> findTop10ActiveUsers(org.springframework.data.domain.Pageable pageable); 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.repository.PostsRepository;
import com.yaoyuan.jiscuss.service.ILikeCollectService; import com.yaoyuan.jiscuss.service.ILikeCollectService;
import com.yaoyuan.jiscuss.service.INotificationService; import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.entity.Discussion;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -29,9 +30,19 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
@Autowired @Autowired
private INotificationService notificationService; private INotificationService notificationService;
@Autowired
private com.yaoyuan.jiscuss.service.IScoreService scoreService;
@Autowired
private com.yaoyuan.jiscuss.repository.UsersRepository usersRepository;
@Transactional @Transactional
@Override @Override
public String voteDiscussion(Integer userId, Integer discussionId, String action) { 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); Optional<LikeCollect> existing = likeCollectRepository.findDiscussionVote(userId, discussionId);
if (existing.isPresent()) { if (existing.isPresent()) {
LikeCollect vote = existing.get(); LikeCollect vote = existing.get();
@@ -39,6 +50,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
// Toggle off: cancel the same action // Toggle off: cancel the same action
likeCollectRepository.delete(vote); likeCollectRepository.delete(vote);
adjustDiscussion(discussionId, action, -1); 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"; return "none";
} else { } else {
// Switch action: undo old, apply new // Switch action: undo old, apply new
@@ -46,6 +61,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
vote.setAction(action); vote.setAction(action);
likeCollectRepository.save(vote); likeCollectRepository.save(vote);
adjustDiscussion(discussionId, action, +1); 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; return action;
} }
} else { } else {
@@ -59,6 +82,10 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
vote.setCreateTime(new Date()); vote.setCreateTime(new Date());
likeCollectRepository.save(vote); likeCollectRepository.save(vote);
adjustDiscussion(discussionId, action, +1); 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; return action;
} }
} }
@@ -72,12 +99,28 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
if (vote.getAction().equals(action)) { if (vote.getAction().equals(action)) {
likeCollectRepository.delete(vote); likeCollectRepository.delete(vote);
adjustPost(postId, action, -1); 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"; return "none";
} else { } else {
adjustPost(postId, vote.getAction(), -1); adjustPost(postId, vote.getAction(), -1);
vote.setAction(action); vote.setAction(action);
likeCollectRepository.save(vote); likeCollectRepository.save(vote);
adjustPost(postId, action, +1); 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; return action;
} }
} else { } else {
@@ -90,11 +133,14 @@ public class LikeCollectServiceImpl implements ILikeCollectService {
vote.setCreateTime(new Date()); vote.setCreateTime(new Date());
likeCollectRepository.save(vote); likeCollectRepository.save(vote);
adjustPost(postId, action, +1); 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)) { if ("like".equals(action)) {
Post post = postsRepository.findById(postId).orElse(null); Post post = postsRepository.findById(postId).orElse(null);
if (post != null && post.getCreateId() != null) { if (post != null && post.getCreateId() != null) {
notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId()); notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId());
if (!post.getCreateId().equals(userId)) {
scoreService.addScore(post.getCreateId(), +2);
}
} }
} }
return action; 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);
}
}
@@ -0,0 +1,23 @@
-- V10: Add score column for the points/level system.
-- score: cumulative activity points (default 0).
-- level is already present; it will be recomputed by ScoreServiceImpl whenever score changes.
ALTER TABLE user ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
-- Back-fill an initial score for existing users based on their activity counts.
-- Formula: discussions_count*10 + comments_count*5 (mirrors live award rates)
UPDATE user
SET score = COALESCE(discussions_count, 0) * 10 + COALESCE(comments_count, 0) * 5;
-- Recompute level for all users based on back-filled score.
-- Thresholds: 0=新手(<50), 1=学徒(50-199), 2=熟手(200-499),
-- 3=达人(500-999), 4=专家(1000-1999), 5=大神(>=2000)
UPDATE user SET level =
CASE
WHEN score >= 2000 THEN 5
WHEN score >= 1000 THEN 4
WHEN score >= 500 THEN 3
WHEN score >= 200 THEN 2
WHEN score >= 50 THEN 1
ELSE 0
END
WHERE level = 0 OR level IS NULL;
+7 -2
View File
@@ -287,10 +287,15 @@
var $card = $('<div id="jUserCard" style="position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px 16px;box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;min-width:180px;display:none;"></div>').appendTo('body'); var $card = $('<div id="jUserCard" style="position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px 16px;box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;min-width:180px;display:none;"></div>').appendTo('body');
function showCard(u, x, y) { function showCard(u, x, y) {
var lvlNames = ['新手','学徒','熟手','达人','专家','大神'];
var lvlColors = ['#95a5a6','#27ae60','#2980b9','#8e44ad','#e67e22','#e74c3c'];
var lvl = Math.min(5, Math.max(0, u.level || 0));
var badge = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;background:' + lvlColors[lvl] + ';color:#fff;font-size:.78em;font-weight:bold;">Lv.' + lvl + ' ' + lvlNames[lvl] + '</span>';
$card.html( $card.html(
'<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + '</div>' + '<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + ' ' + badge + '</div>' +
'<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) + '<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) +
'&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) + '</div>' + '&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) +
'&nbsp;&nbsp;积分: ' + (u.score || 0) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:12px;">' + '<div style="margin-top:8px;display:flex;gap:12px;">' +
'<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' + '<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' +
'<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' + '<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' +
+7 -2
View File
@@ -564,10 +564,15 @@
var cardCache = {}; var cardCache = {};
var $card = $('<div id="jUserCard" style="position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px 16px;box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;min-width:180px;display:none;"></div>').appendTo('body'); var $card = $('<div id="jUserCard" style="position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px 16px;box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;min-width:180px;display:none;"></div>').appendTo('body');
function showCard(u, x, y) { function showCard(u, x, y) {
var lvlNames = ['新手','学徒','熟手','达人','专家','大神'];
var lvlColors = ['#95a5a6','#27ae60','#2980b9','#8e44ad','#e67e22','#e74c3c'];
var lvl = Math.min(5, Math.max(0, u.level || 0));
var badge = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;background:' + lvlColors[lvl] + ';color:#fff;font-size:.78em;font-weight:bold;">Lv.' + lvl + ' ' + lvlNames[lvl] + '</span>';
$card.html( $card.html(
'<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + '</div>' + '<div style="font-weight:700;font-size:1em;margin-bottom:4px;">' + (u.username || '') + ' ' + badge + '</div>' +
'<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) + '<div style="color:#888;font-size:.88em;">发帖: ' + (u.discussionsCount || 0) +
'&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) + '</div>' + '&nbsp;&nbsp;回复: ' + (u.commentsCount || 0) +
'&nbsp;&nbsp;积分: ' + (u.score || 0) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:12px;">' + '<div style="margin-top:8px;display:flex;gap:12px;">' +
'<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' + '<a href="/profile?uid=' + u.id + '" style="font-size:.85em;color:#2185d0;"><i class="user icon"></i>主页</a>' +
'<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' + '<a class="open-msg-modal" data-uid="' + u.id + '" data-uname="' + (u.username||'') + '" style="font-size:.85em;color:#2185d0;cursor:pointer;"><i class="envelope icon"></i>发私信</a>' +
+11 -2
View File
@@ -104,10 +104,19 @@
<div class="label2">回复</div> <div class="label2">回复</div>
</div> </div>
<div class="column profile-stat"> <div class="column profile-stat">
<div class="number">${(userEntity.level)!1}</div> <div class="number">${(userEntity.score)!0}</div>
<div class="label2">等级</div> <div class="label2">积分</div>
</div> </div>
</div> </div>
<div style="text-align:center; margin-top:10px;">
<#assign lvl = (userEntity.level)!0>
<#assign lvlName = ["新手","学徒","熟手","达人","专家","大神"][lvl?int]>
<#assign lvlColors = ["#95a5a6","#27ae60","#2980b9","#8e44ad","#e67e22","#e74c3c"]>
<span style="display:inline-block; padding:3px 12px; border-radius:20px;
background:${lvlColors[lvl?int]}; color:#fff; font-size:.85em; font-weight:bold;">
Lv.${lvl} ${lvlName}
</span>
</div>
</div> </div>
</div> </div>