feat: 用户徽章与等级体系,积分经济管理重构

- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
2026-09-25 16:38:29 +08:00
parent 7b327bc8cc
commit 4ae57d9392
57 changed files with 3939 additions and 147 deletions

View 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})
}