feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
@@ -242,7 +242,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
order = "pinned DESC, created_at DESC"
|
||||
}
|
||||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
|
||||
var posts []model.Post
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := toPostListItems(posts)
|
||||
@@ -330,8 +330,8 @@ func (s *PostService) TogglePin(id uint) (int, error) {
|
||||
return newPinned, nil
|
||||
}
|
||||
|
||||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||||
// ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发)
|
||||
func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
return false, err
|
||||
@@ -341,9 +341,38 @@ func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
if newVal && operatorID != post.UserID {
|
||||
s.grantRecommendReward(post)
|
||||
}
|
||||
return newVal, nil
|
||||
}
|
||||
|
||||
// grantRecommendReward 发放推荐奖励(非关键路径,失败仅记日志不阻断)
|
||||
func (s *PostService) grantRecommendReward(post model.Post) {
|
||||
if s.setting == nil {
|
||||
return
|
||||
}
|
||||
rules, err := s.setting.PointsRules()
|
||||
if err != nil || rules.RecommendReward <= 0 {
|
||||
return
|
||||
}
|
||||
_ = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 每帖仅一次:已存在同 reason/ref 的正入账则跳过(防止反复 toggle 重复加分)
|
||||
var used int64
|
||||
if err := tx.Model(&model.PointLedger{}).
|
||||
Where("user_id = ? AND reason = ? AND ref_type = ? AND ref_id = ? AND delta > 0",
|
||||
post.UserID, model.PointReasonRecommend, "post", post.ID).
|
||||
Count(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if used > 0 {
|
||||
return nil
|
||||
}
|
||||
RewardIfConfigured(tx, post.UserID, rules.RecommendReward, 0, model.PointReasonRecommend, "post", post.ID, "帖子被推荐")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
|
||||
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
|
||||
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
|
||||
@@ -437,13 +466,13 @@ type UpdatePostInput struct {
|
||||
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
err := s.db.Preload("Board").Preload("User").First(&post, id).Error
|
||||
err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 默认 scope 找不到:尝试软删行 → tombstone
|
||||
if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
if err := s.db.Unscoped().Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if !post.DeletedAt.Valid {
|
||||
@@ -655,7 +684,7 @@ func (s *PostService) UnlockByPassword(postID uint, password string) (matched []
|
||||
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
|
||||
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
||||
if err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
derived, derr := markdown.DeriveAccessFromContent(post.Content)
|
||||
@@ -834,6 +863,14 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 发帖奖励(可配置;仅公开发布发奖,待审不发且过审不补发,防拒审刷分)
|
||||
if status == model.ContentStatusPublished && s.setting != nil {
|
||||
rules, err := s.setting.PointsRules()
|
||||
if err == nil {
|
||||
RewardIfConfigured(tx, in.UserID, rules.PostReward, rules.PostDailyCap,
|
||||
model.PointReasonPostReward, "post", post.ID, "发帖奖励:"+title)
|
||||
}
|
||||
}
|
||||
if len(in.AttachmentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -855,7 +892,7 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(post, post.ID)
|
||||
detail := buildPostDetail(post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
@@ -1040,7 +1077,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, post.ID)
|
||||
detail := buildPostDetail(&post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
|
||||
Reference in New Issue
Block a user