feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
67
backend/handler/admin_points.go
Normal file
67
backend/handler/admin_points.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ===== 积分管理(usersAPI 组:RequireStaff + CSRF + PermUsers,超管/站长) =====
|
||||
|
||||
// adminAdjustMaxDelta 单次调整量的绝对值上界(与积分规则值上界同级)
|
||||
const adminAdjustMaxDelta = 100000
|
||||
|
||||
// AdminAdjustUserPoints 管理员手动调整用户积分:
|
||||
// 加分走 CreditTx(同步累加累计获得→可升级),扣分走 DebitTx(仅扣余额、不降级);
|
||||
// 理由必填,写入流水 note(reason=admin_adjust),响应返回调整后余额/累计/等级。
|
||||
func (h *Handlers) AdminAdjustUserPoints(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Delta int `json:"delta"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if body.Delta == 0 || body.Delta > adminAdjustMaxDelta || body.Delta < -adminAdjustMaxDelta {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "积分数量无效"})
|
||||
return
|
||||
}
|
||||
note := strings.TrimSpace(body.Note)
|
||||
if note == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "调整理由必填"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(note) > 200 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "调整理由过长(≤200 字)"})
|
||||
return
|
||||
}
|
||||
bal, total, level, err := h.Points.AdminAdjustPoints(id, body.Delta, note)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"balance": bal, "total_points": total, "level": level})
|
||||
}
|
||||
|
||||
// AdminUserPointLedger 某用户的积分流水(分页,id DESC)
|
||||
func (h *Handlers) AdminUserPointLedger(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
items, total, err := h.Points.AdminUserLedger(id, page, 20)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取积分流水失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page, "size": 20})
|
||||
}
|
||||
@@ -209,7 +209,9 @@ func respondAdminUserError(c *gin.Context, err error) {
|
||||
errors.Is(err, service.ErrInvalidRole),
|
||||
errors.Is(err, service.ErrCannotAssignRole),
|
||||
errors.Is(err, service.ErrBoardRequired),
|
||||
errors.Is(err, service.ErrBoardNotFound):
|
||||
errors.Is(err, service.ErrBoardNotFound),
|
||||
errors.Is(err, service.ErrInsufficientPoints),
|
||||
errors.Is(err, service.ErrInvalidPoints):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||
|
||||
@@ -342,12 +342,13 @@ func (h *Handlers) RevokeLoginDevice(c *gin.Context) {
|
||||
}
|
||||
|
||||
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
|
||||
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组
|
||||
func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
||||
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组。
|
||||
// badges 为管理员颁发徽章(Header 用户菜单展示;查询失败时省略)。
|
||||
func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin.H {
|
||||
if boardIDs == nil {
|
||||
boardIDs = []uint{}
|
||||
}
|
||||
return gin.H{
|
||||
body := gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
@@ -357,7 +358,13 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
||||
"role": user.Role,
|
||||
"board_ids": boardIDs,
|
||||
"can_manage_messages": user.CanManageMessages,
|
||||
"level": user.Level,
|
||||
"total_points": user.TotalPoints,
|
||||
}
|
||||
if badges != nil {
|
||||
body["badges"] = badges
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
|
||||
@@ -394,7 +401,12 @@ func (h *Handlers) Me(c *gin.Context) {
|
||||
if user.Role == model.RoleBoardAdmin {
|
||||
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
||||
}
|
||||
body := meUserBody(user, boardIDs)
|
||||
// 颁发徽章(Header 用户菜单展示)
|
||||
var badges []model.UserBadge
|
||||
if h.Badge != nil {
|
||||
badges, _ = h.Badge.ForUser(claims.ID)
|
||||
}
|
||||
body := meUserBody(user, boardIDs, badges)
|
||||
// 群管理员数量:非站长/超管用于后台消息入口判定
|
||||
if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin {
|
||||
if n, err := h.Chat.CountAdminRooms(user.ID); err == nil {
|
||||
|
||||
151
backend/handler/badge.go
Normal file
151
backend/handler/badge.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
)
|
||||
|
||||
type badgeRequest struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type badgeGrantRequest struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
}
|
||||
|
||||
// badgeError 按业务错误映射响应
|
||||
func badgeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrBadgeNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrBadgeNameExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrBadgeInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "徽章名称(1-16 字)或颜色无效"})
|
||||
case errors.Is(err, service.ErrBadgeUserGone):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "徽章操作失败"})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListBadges 全部徽章(含持有数)
|
||||
func (h *Handlers) AdminListBadges(c *gin.Context) {
|
||||
badges, err := h.Badge.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取徽章列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"badges": badges})
|
||||
}
|
||||
|
||||
// AdminCreateBadge 新建徽章
|
||||
func (h *Handlers) AdminCreateBadge(c *gin.Context) {
|
||||
var req badgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
badge, err := h.Badge.Create(req.Name, req.Icon, req.Color)
|
||||
if err != nil {
|
||||
badgeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"badge": badge})
|
||||
}
|
||||
|
||||
// AdminUpdateBadge 修改徽章
|
||||
func (h *Handlers) AdminUpdateBadge(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var req badgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
badge, err := h.Badge.Update(uint(id), req.Name, req.Icon, req.Color)
|
||||
if err != nil {
|
||||
badgeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"badge": badge})
|
||||
}
|
||||
|
||||
// AdminDeleteBadge 删除徽章(级联删除授予记录)
|
||||
func (h *Handlers) AdminDeleteBadge(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Badge.Delete(uint(id)); err != nil {
|
||||
badgeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminGrantBadge 授予徽章(幂等)
|
||||
func (h *Handlers) AdminGrantBadge(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var req badgeGrantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Badge.Grant(uint(id), req.UserID, claims.ID); err != nil {
|
||||
badgeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminRevokeBadge 撤销授予
|
||||
func (h *Handlers) AdminRevokeBadge(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Badge.Revoke(uint(id), uint(userID)); err != nil {
|
||||
badgeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminBadgeHolders 某徽章全部持有者(后台授予面板用)
|
||||
func (h *Handlers) AdminBadgeHolders(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
holders, err := h.Badge.Holders(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取持有者失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"holders": holders})
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Handlers struct {
|
||||
HidePwd *service.HidePasswordCookie
|
||||
Ads *service.AdService
|
||||
Sidebar *service.SidebarService
|
||||
Badge *service.BadgeService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
@@ -530,7 +530,8 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
rec, err := h.Post.ToggleRecommend(uint(id))
|
||||
claims := middleware.CurrentUser(c)
|
||||
rec, err := h.Post.ToggleRecommend(uint(id), claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -57,6 +58,18 @@ type updateSettingsRequest struct {
|
||||
BrandLogoSize *string `json:"brand_logo_size"`
|
||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||
|
||||
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||
PointsStreakEveryDays *int `json:"points_streak_every_days"`
|
||||
PointsStreakBonus *int `json:"points_streak_bonus"`
|
||||
PointsPostReward *int `json:"points_post_reward"`
|
||||
PointsPostDailyCap *int `json:"points_post_daily_cap"`
|
||||
PointsReplyReward *int `json:"points_reply_reward"`
|
||||
PointsReplyDailyCap *int `json:"points_reply_daily_cap"`
|
||||
PointsRecommendReward *int `json:"points_recommend_reward"`
|
||||
|
||||
Levels *[]model.LevelDef `json:"levels"`
|
||||
LevelsFx *bool `json:"levels_fx"`
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -97,6 +110,18 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"brand_logo_size": saved.BrandLogoSize,
|
||||
"brand_logo_fit": saved.BrandLogoFit,
|
||||
"footer_links": saved.FooterLinks,
|
||||
|
||||
"points_checkin_base": saved.PointsCheckinBase,
|
||||
"points_streak_every_days": saved.PointsStreakEveryDays,
|
||||
"points_streak_bonus": saved.PointsStreakBonus,
|
||||
"points_post_reward": saved.PointsPostReward,
|
||||
"points_post_daily_cap": saved.PointsPostDailyCap,
|
||||
"points_reply_reward": saved.PointsReplyReward,
|
||||
"points_reply_daily_cap": saved.PointsReplyDailyCap,
|
||||
"points_recommend_reward": saved.PointsRecommendReward,
|
||||
|
||||
"levels": saved.Levels,
|
||||
"levels_fx": saved.LevelsFx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +138,11 @@ func (req *updateSettingsRequest) hasAny() bool {
|
||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||
req.FooterLinks != nil
|
||||
req.FooterLinks != nil ||
|
||||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
|
||||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil
|
||||
}
|
||||
|
||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||
@@ -218,6 +247,49 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
pointsRules := []struct {
|
||||
req *int
|
||||
set func(int) error
|
||||
hint string
|
||||
}{
|
||||
{req.PointsCheckinBase, h.Setting.SetPointsCheckinBase, "签到基础分须为 0–100"},
|
||||
{req.PointsStreakEveryDays, h.Setting.SetPointsStreakEveryDays, "连续签到触发天数须为 0–100"},
|
||||
{req.PointsStreakBonus, h.Setting.SetPointsStreakBonus, "连续签到加成分须为 0–100"},
|
||||
{req.PointsPostReward, h.Setting.SetPointsPostReward, "发帖奖励须为 0–100"},
|
||||
{req.PointsPostDailyCap, h.Setting.SetPointsPostDailyCap, "发帖奖励每日上限须为 0–100"},
|
||||
{req.PointsReplyReward, h.Setting.SetPointsReplyReward, "回复奖励须为 0–100"},
|
||||
{req.PointsReplyDailyCap, h.Setting.SetPointsReplyDailyCap, "回复奖励每日上限须为 0–100"},
|
||||
{req.PointsRecommendReward, h.Setting.SetPointsRecommendReward, "推荐奖励须为 0–100"},
|
||||
}
|
||||
for _, r := range pointsRules {
|
||||
if r.req == nil {
|
||||
continue
|
||||
}
|
||||
if err := r.set(*r.req); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": r.hint})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Levels != nil {
|
||||
if err := h.Setting.SetLevels(*req.Levels); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "等级配置无效(2–12 级,阈值须非负且严格递增、首级为 0,颜色须在色板内)"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.LevelsFx != nil {
|
||||
if err := h.Setting.SetLevelsFx(*req.LevelsFx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.CodeBlockAutoFold != nil {
|
||||
if err := h.Setting.SetCodeBlockAutoFold(*req.CodeBlockAutoFold); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
|
||||
@@ -109,19 +109,27 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
userPayload := gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"signature": user.Signature,
|
||||
"role": user.Role,
|
||||
"board_ids": boardIDs,
|
||||
"created_at": user.CreatedAt,
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"signature": user.Signature,
|
||||
"role": user.Role,
|
||||
"board_ids": boardIDs,
|
||||
"created_at": user.CreatedAt,
|
||||
"level": user.Level,
|
||||
"total_points": user.TotalPoints,
|
||||
}
|
||||
// 仅对具备用户管理权限的访客下发封禁态(避免信息公开)
|
||||
if actor != nil && actor.HasPerm(service.PermUsers) {
|
||||
userPayload["banned"] = user.Banned
|
||||
}
|
||||
// 徽章墙(失败不阻塞资料返回)
|
||||
if h.Badge != nil {
|
||||
if badges, err := h.Badge.ForUser(user.ID); err == nil && badges != nil {
|
||||
userPayload["badges"] = badges
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": userPayload,
|
||||
|
||||
Reference in New Issue
Block a user