增加用户认证、等级、徽章与积分体系,并优化管理后台体验。

覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 02:42:18 +08:00
parent b075495540
commit 6b0a1d4281
44 changed files with 4455 additions and 254 deletions

View File

@@ -29,8 +29,17 @@ func (h *Handlers) APIMe(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
if h.Badge != nil {
_ = h.Badge.EvaluateAuto(user.ID)
}
view := user.ToSelf()
if h.Badge != nil {
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
view.Badges = service.BadgeViews(badges, 0)
}
}
c.JSON(http.StatusOK, gin.H{
"user": user.ToSelf(),
"user": view,
})
}
@@ -123,6 +132,9 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
pendingPosts, _ := h.Post.PendingPostCount()
pendingComments, _ := h.Comment.PendingCommentCount()
pendingReports, _ := h.Report.PendingCount()
recentPosts, _, _ := h.Post.List(service.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
})
@@ -132,7 +144,10 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount,
"recent_posts": recentPosts,
"pending_posts": pendingPosts,
"pending_comments": pendingComments,
"pending_reports": pendingReports,
"recent_posts": recentPosts,
})
}
@@ -375,7 +390,11 @@ func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) {
func (h *Handlers) APIAdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
users, total, err := h.User.ListUsers(page, size)
keyword := strings.TrimSpace(c.Query("keyword"))
filter := strings.TrimSpace(c.DefaultQuery("filter", "all"))
users, total, err := h.User.ListUsers(service.UserListQuery{
Page: page, Size: size, Keyword: keyword, Filter: filter,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -386,6 +405,8 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"users": model.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
"keyword": keyword,
"filter": filter,
})
}
@@ -853,6 +874,15 @@ func (h *Handlers) APIPosts(c *gin.Context) {
if items == nil {
items = []service.PostListItem{}
}
if h.Badge != nil {
users := make([]*model.User, 0, len(items))
for i := range items {
if items[i].User.ID > 0 {
users = append(users, &items[i].User)
}
}
h.Badge.AttachBadgeSummaries(users, 2)
}
c.JSON(http.StatusOK, gin.H{
"posts": items,
"total": total,
@@ -889,7 +919,20 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
// 作者与管理员始终可见;其他用户需已回复
post.Content = service.RedactReplyOnlyHTML(post.Content)
}
// 积分解锁块:作者/站长全文;其他人按解锁记录 redact
if isAdmin || post.UserID == uid {
post.Content = service.RevealAllPointsOnly(post.Content)
} else {
unlocked, _ := service.ListUnlockedKeys(uid, uint(id))
post.Content = service.RedactPointsOnlyHTML(post.Content, unlocked)
}
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
if h.Badge != nil {
if post.User.ID > 0 {
h.Badge.AttachBadgeSummaries([]*model.User{&post.User}, 3)
}
h.Badge.AttachBadgeSummariesOnComments(comments, 2)
}
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
@@ -931,6 +974,9 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
if comments == nil {
comments = []model.Comment{}
}
if h.Badge != nil {
h.Badge.AttachBadgeSummariesOnComments(comments, 2)
}
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
}

208
handler/economy.go Normal file
View File

@@ -0,0 +1,208 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
)
// APIMePoints 余额与流水
func (h *Handlers) APIMePoints(c *gin.Context) {
uid := h.currentUserID(c)
user, err := h.User.GetByID(uid)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
rows, total, err := h.Points.ListLedger(uid, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
checkIn, _ := h.Points.GetCheckInStatus(uid)
lottery, _ := h.Points.GetLotteryStatus(uid)
c.JSON(http.StatusOK, gin.H{
"points": user.Points,
"creator_income_total": user.CreatorIncomeTotal,
"ledger": rows,
"total": total,
"page": page,
"total_pages": calcTotalPages(total, size),
"check_in": checkIn,
"lottery": lottery,
})
}
// APIMeCheckIn 每日签到
func (h *Handlers) APIMeCheckIn(c *gin.Context) {
st, err := h.Points.CheckIn(h.currentUserID(c))
if err != nil {
if errors.Is(err, service.ErrAlreadyCheckedIn) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, _ := h.User.GetByID(h.currentUserID(c))
pts := 0
if user != nil {
pts = user.Points
}
c.JSON(http.StatusOK, gin.H{"message": "签到成功", "check_in": st, "points": pts})
}
// APIMeLottery GET 状态 / POST 抽奖
func (h *Handlers) APIMeLotteryGet(c *gin.Context) {
st, err := h.Points.GetLotteryStatus(h.currentUserID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"lottery": st})
}
func (h *Handlers) APIMeLotteryDraw(c *gin.Context) {
st, err := h.Points.DrawLottery(h.currentUserID(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, _ := h.User.GetByID(h.currentUserID(c))
pts := 0
if user != nil {
pts = user.Points
}
c.JSON(http.StatusOK, gin.H{"message": "抽奖完成", "lottery": st, "points": pts})
}
// APIUnlockPostBlock 积分解锁付费块
func (h *Handlers) APIUnlockPostBlock(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
BlockKey string `json:"block_key"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.BlockKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
return
}
res, err := service.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "解锁成功", "unlock": res})
}
// APIAdminVerifyUser 认证开关
func (h *Handlers) APIAdminVerifyUser(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Verified bool `json:"verified"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := service.SetVerified(uint(id), req.Verified); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消认证"
if req.Verified {
msg = "已认证"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "verified": req.Verified})
}
// APIAdminSetUserLevel 设等级
func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Level int `json:"level"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := service.SetUserLevel(uint(id), req.Level); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": model.ExpForLevel(req.Level)})
}
// APIAdminAdjustPoints 调积分
func (h *Handlers) APIAdminAdjustPoints(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Delta int `json:"delta"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
bal, err := h.Points.AdminAdjust(uint(id), req.Delta, req.Note)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "积分已调整", "points": bal})
}
// APIAdminListBadges 徽章定义列表
func (h *Handlers) APIAdminListBadges(c *gin.Context) {
rows, err := h.Badge.ListDefs(true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"badges": rows})
}
// APIAdminUpsertBadge 创建/更新徽章定义
func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) {
var def model.BadgeDef
if err := c.ShouldBindJSON(&def); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Badge.UpsertDef(&def); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已保存", "badge": def})
}
// APIAdminAwardBadge 颁发/收回限定徽章
func (h *Handlers) APIAdminAwardBadge(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
BadgeID uint `json:"badge_id"`
Revoke bool `json:"revoke"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.BadgeID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if req.Revoke {
if err := h.Badge.Revoke(uint(id), req.BadgeID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已收回徽章"})
return
}
if err := h.Badge.AwardLimited(uint(id), req.BadgeID, h.currentUserID(c)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已颁发徽章"})
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
@@ -35,6 +36,8 @@ type Handlers struct {
EmailCode *service.EmailCodeService
OIDC *service.OIDCService
Gitea *service.GiteaService
Points *service.PointsService
Badge *service.BadgeService
}
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
@@ -55,6 +58,23 @@ func (h *Handlers) isAdmin(c *gin.Context) bool {
return false
}
// loadCurrentUser 加载当前登录用户完整资料(含认证/积分)
func (h *Handlers) loadCurrentUser(c *gin.Context) (*model.User, error) {
uid := h.currentUserID(c)
if uid == 0 {
return nil, errors.New("未登录")
}
return h.User.GetByID(uid)
}
func (h *Handlers) skipsModeration(c *gin.Context) bool {
u, err := h.loadCurrentUser(c)
if err != nil {
return h.isAdmin(c)
}
return u.SkipsModeration()
}
func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint {
raw := c.Query("my_ids")
if raw == "" {
@@ -226,8 +246,15 @@ func (h *Handlers) APIUserPublic(c *gin.Context) {
if viewerID != user.ID {
st.FavoriteCount = 0
}
view := user.ToPublic()
if h.Badge != nil {
_ = h.Badge.EvaluateAuto(user.ID)
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
view.Badges = service.BadgeViews(badges, 0)
}
}
c.JSON(http.StatusOK, gin.H{
"user": user.ToPublic(),
"user": view,
"stats": st,
})
}
@@ -315,7 +342,8 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
content := c.PostForm("content")
tags := c.PostForm("tags")
postType := c.PostForm("post_type")
post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, h.isAdmin(c))
skip := h.skipsModeration(c)
post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, skip)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -334,14 +362,15 @@ func (h *Handlers) APIUpdatePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
isAdmin := h.isAdmin(c)
err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin,
skip := h.skipsModeration(c)
err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, skip,
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 普通用户修改后重新进入审核
if !isAdmin && h.Notify != nil {
// 非免审用户修改后重新进入审核
if !skip && h.Notify != nil {
if post, getErr := h.Post.FindByID(uint(id)); getErr == nil {
h.Notify.AsyncNotifyPendingPost(post)
}
@@ -461,7 +490,8 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
func (h *Handlers) APIUpdateComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
content := c.PostForm("content")
saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
skip := h.skipsModeration(c)
saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), skip, content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return