From 4ae57d939214929e30e079a5fe97152c9f7b9c38 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 25 Sep 2026 16:38:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E5=BE=BD=E7=AB=A0?= =?UTF-8?q?=E4=B8=8E=E7=AD=89=E7=BA=A7=E4=BD=93=E7=B3=BB=EF=BC=8C=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E7=BB=8F=E6=B5=8E=E7=AE=A1=E7=90=86=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑 --- backend/handler/admin_points.go | 67 +++ backend/handler/admin_user.go | 4 +- backend/handler/auth.go | 20 +- backend/handler/badge.go | 151 ++++++ backend/handler/handlers.go | 1 + backend/handler/post.go | 3 +- backend/handler/setting.go | 74 ++- backend/handler/user.go | 24 +- backend/model/db.go | 34 ++ backend/model/level.go | 90 ++++ backend/model/level_test.go | 106 ++++ backend/model/models.go | 48 +- backend/router/router.go | 23 +- backend/service/admin_user.go | 8 +- backend/service/badge.go | 240 +++++++++ backend/service/badge_test.go | 159 ++++++ backend/service/board.go | 2 +- backend/service/chat.go | 6 +- backend/service/checkin.go | 77 ++- backend/service/comment.go | 25 +- backend/service/overview.go | 2 +- backend/service/points.go | 71 ++- backend/service/post.go | 55 +- backend/service/post_interact.go | 8 +- backend/service/setting.go | 222 ++++++++ frontend/app/admin/badges/BadgesClient.tsx | 441 ++++++++++++++++ frontend/app/admin/badges/page.tsx | 22 + frontend/app/admin/economy/page.tsx | 37 -- frontend/app/admin/levels/LevelsAdmin.tsx | 309 ++++++++++++ frontend/app/admin/levels/page.tsx | 22 + frontend/app/admin/page.tsx | 4 +- .../EconomyOverview.tsx} | 0 frontend/app/admin/points/PointsTabs.tsx | 93 ++++ frontend/app/admin/points/UserPointsTab.tsx | 477 ++++++++++++++++++ frontend/app/admin/points/page.tsx | 44 ++ .../app/admin/settings/EconomySettings.tsx | 237 +++++++++ frontend/app/chat/[id]/ChatRoomClient.tsx | 10 + frontend/app/globals.css | 123 ++++- frontend/app/layout.tsx | 8 + .../app/notifications/NotificationsClient.tsx | 22 +- frontend/app/post/[id]/page.tsx | 6 + frontend/app/u/[id]/page.tsx | 15 + frontend/components/BadgeChip.tsx | 33 ++ frontend/components/ChatUserCard.tsx | 6 + frontend/components/CommentSection.tsx | 14 +- frontend/components/Header.tsx | 10 +- frontend/components/LevelBadge.tsx | 45 ++ frontend/components/NotificationBell.tsx | 9 +- frontend/components/PostRow.tsx | 4 + frontend/components/RoleBadge.tsx | 2 +- frontend/components/admin/AdminShell.tsx | 97 +++- frontend/components/post/PostRailBlocks.tsx | 18 +- frontend/lib/api.ts | 265 +++++++++- frontend/lib/badgeIcons.ts | 47 ++ frontend/lib/levels.ts | 137 +++++ frontend/lib/points.ts | 5 + frontend/next-env.d.ts | 4 +- 57 files changed, 3939 insertions(+), 147 deletions(-) create mode 100644 backend/handler/admin_points.go create mode 100644 backend/handler/badge.go create mode 100644 backend/model/level.go create mode 100644 backend/model/level_test.go create mode 100644 backend/service/badge.go create mode 100644 backend/service/badge_test.go create mode 100644 frontend/app/admin/badges/BadgesClient.tsx create mode 100644 frontend/app/admin/badges/page.tsx delete mode 100644 frontend/app/admin/economy/page.tsx create mode 100644 frontend/app/admin/levels/LevelsAdmin.tsx create mode 100644 frontend/app/admin/levels/page.tsx rename frontend/app/admin/{economy/EconomyClient.tsx => points/EconomyOverview.tsx} (100%) create mode 100644 frontend/app/admin/points/PointsTabs.tsx create mode 100644 frontend/app/admin/points/UserPointsTab.tsx create mode 100644 frontend/app/admin/points/page.tsx create mode 100644 frontend/app/admin/settings/EconomySettings.tsx create mode 100644 frontend/components/BadgeChip.tsx create mode 100644 frontend/components/LevelBadge.tsx create mode 100644 frontend/lib/badgeIcons.ts create mode 100644 frontend/lib/levels.ts diff --git a/backend/handler/admin_points.go b/backend/handler/admin_points.go new file mode 100644 index 0000000..0fdef00 --- /dev/null +++ b/backend/handler/admin_points.go @@ -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}) +} diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index 4a1fad4..6fa82a6 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -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": "操作失败"}) diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 082a41f..7869f38 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -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 { diff --git a/backend/handler/badge.go b/backend/handler/badge.go new file mode 100644 index 0000000..0559c0a --- /dev/null +++ b/backend/handler/badge.go @@ -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}) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 7a2fcdc..04d3317 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -36,6 +36,7 @@ type Handlers struct { HidePwd *service.HidePasswordCookie Ads *service.AdService Sidebar *service.SidebarService + Badge *service.BadgeService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/post.go b/backend/handler/post.go index 803e858..05c71b0 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -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 diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 3fe5098..50fbc80 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -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": "保存站点设置失败"}) diff --git a/backend/handler/user.go b/backend/handler/user.go index 27a0208..73f9320 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -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, diff --git a/backend/model/db.go b/backend/model/db.go index 20e1cdf..8fb4a3f 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -61,6 +61,7 @@ func InitDB(dsn string) error { &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, + &Badge{}, &UserBadge{}, &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &ImportRecord{}, ); err != nil { @@ -73,6 +74,10 @@ func InitDB(dsn string) error { return fmt.Errorf("站长角色迁移失败: %w", err) } + // 用户等级:按积分流水回填累计获得积分(经验值,LevelOf 定级依据)。 + // 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund) + backfillUserTotalPoints(db) + // login_logs.success 早期 default=true 与 GORM 零值省略叠加, // 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正 if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil { @@ -334,6 +339,35 @@ func ensureOwnerRole(db *gorm.DB) error { return nil } +// backfillUserTotalPoints 按积分流水回填累计获得积分(经验值)。 +// 口径与 CreditTx 增量一致:仅计正入账,排除悬赏托管退回(bounty_refund)。 +// 每次启动执行,幂等自愈;失败仅记日志不阻断启动(等级纯展示,可下次启动修复)。 +func backfillUserTotalPoints(db *gorm.DB) { + var tableCount int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'point_ledgers'`). + Scan(&tableCount).Error; err != nil { + log.Printf("[model] 回填累计获得积分前置检查失败: %v", err) + return + } + if tableCount == 0 { + return + } + res := db.Exec(` + UPDATE users SET total_points = COALESCE(( + SELECT SUM(delta) FROM point_ledgers + WHERE point_ledgers.user_id = users.id AND delta > 0 AND reason <> 'bounty_refund' + ), 0) + WHERE deleted_at IS NULL + `) + if res.Error != nil { + log.Printf("[model] 回填累计获得积分失败: %v", res.Error) + return + } + if res.RowsAffected > 0 { + log.Printf("[model] 已按积分流水回填累计获得积分 %d 行", res.RowsAffected) + } +} + // backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。 // 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填 // post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。 diff --git a/backend/model/level.go b/backend/model/level.go new file mode 100644 index 0000000..0faa9ec --- /dev/null +++ b/backend/model/level.go @@ -0,0 +1,90 @@ +package model + +import ( + "sync/atomic" +) + +// 用户等级:按「累计获得积分」(User.TotalPoints)定级,消费不减经验。 +// 等级体系(数量/阈值/颜色)由管理员在后台配置,持久化于 site_settings 的 levels_config 键; +// 进程内持有一份原子快照供 LevelOf 纯算术计算,配置保存时刷新,无需重启。 + +// LevelDef 单级配置:Threshold 为该级累计积分下限(Lv1 恒为 0),Color 为展示色板 key +type LevelDef struct { + Threshold int `json:"threshold"` + Color string `json:"color"` +} + +const ( + // MinLevelCount / MaxLevelCount 等级数量边界(前端 CSS 变量按 MaxLevelCount 档固定下发) + MinLevelCount = 2 + MaxLevelCount = 12 +) + +// DefaultLevelDefs 默认 7 级(与历史硬编码阶梯一致),缺行/配置非法时回落 +func DefaultLevelDefs() []LevelDef { + return []LevelDef{ + {Threshold: 0, Color: "neutral"}, + {Threshold: 50, Color: "info"}, + {Threshold: 200, Color: "teal"}, + {Threshold: 500, Color: "ok"}, + {Threshold: 1200, Color: "accent"}, + {Threshold: 3000, Color: "purple"}, + {Threshold: 8000, Color: "gold"}, + } +} + +// levelSnapshot 当前生效的等级配置([]LevelDef) +var levelSnapshot atomic.Value + +func init() { + levelSnapshot.Store(DefaultLevelDefs()) +} + +// ValidLevelDefs 结构校验:数量 2–12、阈值非负且严格递增、首级为 0(色板合法性由 service 层校验) +func ValidLevelDefs(defs []LevelDef) bool { + if len(defs) < MinLevelCount || len(defs) > MaxLevelCount { + return false + } + if defs[0].Threshold != 0 { + return false + } + for i, d := range defs { + if d.Threshold < 0 { + return false + } + if i > 0 && d.Threshold <= defs[i-1].Threshold { + return false + } + } + return true +} + +// ApplyLevelDefs 刷新进程内等级快照;非法/空配置回落默认表 +func ApplyLevelDefs(defs []LevelDef) { + if !ValidLevelDefs(defs) { + defs = DefaultLevelDefs() + } + levelSnapshot.Store(append([]LevelDef(nil), defs...)) +} + +// LevelDefs 当前生效的等级配置 +func LevelDefs() []LevelDef { + if v, ok := levelSnapshot.Load().([]LevelDef); ok && len(v) > 0 { + return v + } + return DefaultLevelDefs() +} + +// LevelOf 由累计获得积分计算等级,恒返回 1..len(当前配置) +func LevelOf(totalPoints int) int { + defs := LevelDefs() + level := 1 + for i := 1; i < len(defs); i++ { + if totalPoints >= defs[i].Threshold { + level = i + 1 + } else { + break + } + } + return level +} diff --git a/backend/model/level_test.go b/backend/model/level_test.go new file mode 100644 index 0000000..1feb499 --- /dev/null +++ b/backend/model/level_test.go @@ -0,0 +1,106 @@ +package model + +import "testing" + +// withLevelDefs 临时切换等级快照并在测试结束后恢复现场(快照为进程级全局态) +func withLevelDefs(t *testing.T, defs []LevelDef) { + t.Helper() + prev := LevelDefs() + t.Cleanup(func() { ApplyLevelDefs(prev) }) + ApplyLevelDefs(defs) +} + +func TestLevelOfDefault(t *testing.T) { + withLevelDefs(t, DefaultLevelDefs()) + cases := []struct { + total int + want int + }{ + {0, 1}, + {49, 1}, + {50, 2}, + {51, 2}, + {199, 2}, + {200, 3}, + {500, 4}, + {1200, 5}, + {3000, 6}, + {7999, 6}, + {8000, 7}, + {1 << 30, 7}, + {-5, 1}, + } + for _, c := range cases { + if got := LevelOf(c.total); got != c.want { + t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want) + } + } +} + +func TestLevelOfCustomConfig(t *testing.T) { + withLevelDefs(t, []LevelDef{ + {Threshold: 0, Color: "neutral"}, + {Threshold: 10, Color: "gold"}, + {Threshold: 100, Color: "info"}, + }) + cases := []struct { + total int + want int + }{ + {0, 1}, + {9, 1}, + {10, 2}, + {99, 2}, + {100, 3}, + {1 << 20, 3}, + } + for _, c := range cases { + if got := LevelOf(c.total); got != c.want { + t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want) + } + } +} + +func TestApplyLevelDefsFallback(t *testing.T) { + withLevelDefs(t, DefaultLevelDefs()) + // 非法配置(首级阈值非 0、数量不足、非递增)一律回落默认表 + invalid := [][]LevelDef{ + {{Threshold: 10, Color: "gold"}, {Threshold: 50, Color: "info"}}, + {{Threshold: 0, Color: "neutral"}}, + {{Threshold: 0, Color: "neutral"}, {Threshold: 50, Color: "info"}, {Threshold: 50, Color: "ok"}}, + {{Threshold: 0, Color: "neutral"}, {Threshold: -1, Color: "info"}}, + } + for _, defs := range invalid { + ApplyLevelDefs(defs) + got := LevelDefs() + if len(got) != len(DefaultLevelDefs()) { + t.Fatalf("ApplyLevelDefs(invalid) 未回落默认表: got %d levels", len(got)) + } + } +} + +func TestValidLevelDefs(t *testing.T) { + valid := []LevelDef{{Threshold: 0, Color: "neutral"}, {Threshold: 1, Color: "gold"}} + if !ValidLevelDefs(valid) { + t.Errorf("ValidLevelDefs(合法两极) = false") + } + tooMany := make([]LevelDef, MaxLevelCount+1) + for i := range tooMany { + tooMany[i] = LevelDef{Threshold: i, Color: "gold"} + } + if ValidLevelDefs(tooMany) { + t.Errorf("ValidLevelDefs(超上限) = true") + } +} + +func TestLevelDefsBoundaryCount(t *testing.T) { + withLevelDefs(t, DefaultLevelDefs()) + defs := make([]LevelDef, MaxLevelCount) + for i := range defs { + defs[i] = LevelDef{Threshold: i * 10, Color: "gold"} + } + ApplyLevelDefs(defs) + if got := LevelOf(1 << 30); got != MaxLevelCount { + t.Errorf("LevelOf(极大值) = %d, want %d", got, MaxLevelCount) + } +} diff --git a/backend/model/models.go b/backend/model/models.go index 951f7ca..5c27f03 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -123,11 +123,22 @@ type User struct { Banned bool `gorm:"default:false" json:"banned"` CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管) Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 + TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减) + Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库) TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效 LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 管理员颁发的徽章;仅显式 Preload("User.Badges.Badge") 的查询点加载,未加载时省略 + Badges []UserBadge `gorm:"foreignKey:UserID" json:"badges,omitempty"` +} + +// AfterFind 由累计获得积分计算等级(纯算术,随行加载,所有序列化 User 的接口自动携带 level) +func (u *User) AfterFind(tx *gorm.DB) error { + u.Level = LevelOf(u.TotalPoints) + return nil } // RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测) @@ -276,6 +287,32 @@ type PointLedger struct { CreatedAt time.Time `gorm:"index" json:"created_at"` } +// Badge 管理员自定义徽章定义(名称/图标/颜色) +type Badge struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:32;not null;uniqueIndex" json:"name"` // ≤16 字(runes 校验见 service 层) + Icon string `gorm:"size:32;not null;default:''" json:"icon"` // lucide 图标名(前端白名单渲染,未知回落 Medal) + Color string `gorm:"size:16;not null;default:''" json:"color"` // CSS token 色板键(gold/accent/ok/danger/info/purple/teal) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserBadge 徽章授予记录(同一徽章每用户至多一枚) +type UserBadge struct { + ID uint `gorm:"primaryKey" json:"id"` + BadgeID uint `gorm:"not null;uniqueIndex:idx_badge_user" json:"badge_id"` + UserID uint `gorm:"not null;uniqueIndex:idx_badge_user;index" json:"user_id"` + Badge Badge `gorm:"foreignKey:BadgeID" json:"badge"` // 嵌套 Preload("User.Badges.Badge") 时填充 + AwardedBy uint `json:"awarded_by"` + AwardedAt time.Time `json:"awarded_at"` +} + +// 徽章色板键(对应前端 CSS token -- / ---soft) +var BadgeColors = map[string]bool{ + "gold": true, "accent": true, "ok": true, "danger": true, + "info": true, "purple": true, "teal": true, +} + // 积分流水原因 const ( PointReasonCheckin = "checkin" @@ -285,6 +322,11 @@ const ( PointReasonBountyEscrow = "bounty_escrow" PointReasonBountyRefund = "bounty_refund" PointReasonBountyAward = "bounty_award" + PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭) + PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭) + PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成 + PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次) + PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额) ) // PostPollVote 投票记录(多选时同一用户多行) @@ -396,6 +438,7 @@ const ( NotificationTypeRejected = "rejected" // 内容审核未通过 NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID) NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者) + NotificationTypeBadge = "badge" // 获得管理员颁发的徽章 NotificationTypeDeleted = "deleted" // 帖子被管理员删除 ) @@ -414,8 +457,9 @@ type Notification struct { CreatedAt time.Time `json:"created_at"` Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"` - Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` - Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"` + // PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键 + Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"` + Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"` } // Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色) diff --git a/backend/router/router.go b/backend/router/router.go index 95891cb..6f05676 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -58,14 +58,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { authSvc := service.NewAuthService(model.DB, cfg.JWTSecret) boardSvc := service.NewBoardService(model.DB) settingSvc := service.NewSettingService(model.DB) + // 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启) + model.ApplyLevelDefs(settingSvc.Levels()) ops := service.NewOperations(model.DB, cfg) go ops.Run(context.Background()) postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithBoard(boardSvc).WithDevMode(cfg.DevMode) - commentSvc := service.NewCommentService(model.DB) + commentSvc := service.NewCommentService(model.DB).WithSetting(settingSvc) likeSvc := service.NewLikeService(model.DB) notifSvc := service.NewNotificationService(model.DB) overviewSvc := service.NewOverviewService(model.DB) - checkinSvc := service.NewCheckinService(model.DB) + checkinSvc := service.NewCheckinService(model.DB).WithSetting(settingSvc) announcementSvc := service.NewAnnouncementService(model.DB) sitePageSvc := service.NewSitePageService(model.DB) uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc) @@ -77,6 +79,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) chatSvc := service.NewChatService(model.DB, notifSvc) + badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc) visitSvc := service.NewVisitStatsService(model.DB, settingSvc) analyticsSvc := service.NewAnalyticsService(model.DB) adSvc := service.NewAdService(model.DB, service.NewCaptchaStore()) @@ -122,6 +125,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), Ads: adSvc, Sidebar: service.NewSidebarService(model.DB), + Badge: badgeSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -340,6 +344,18 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.GET("/sidebar", authMW.RequirePerm(service.PermSettings), h.AdminGetSidebar) staffAPI.PUT("/sidebar/config", authMW.RequirePerm(service.PermSettings), h.AdminSaveSidebarConfig) staffAPI.POST("/sidebar/widgets", authMW.RequirePerm(service.PermSettings), h.AdminCreateSidebarWidget) + + // 徽章管理(超级管理员/站长):定义 CRUD + 授予/撤销 + badgeAPI := staffAPI.Group("/badges", authMW.RequirePerm(service.PermSettings)) + { + badgeAPI.GET("", h.AdminListBadges) + badgeAPI.POST("", h.AdminCreateBadge) + badgeAPI.PUT("/:id", h.AdminUpdateBadge) + badgeAPI.DELETE("/:id", h.AdminDeleteBadge) + badgeAPI.GET("/:id/holders", h.AdminBadgeHolders) + badgeAPI.POST("/:id/grant", h.AdminGrantBadge) + badgeAPI.DELETE("/:id/grant/:userId", h.AdminRevokeBadge) + } staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget) staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget) @@ -368,6 +384,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages) usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) + // 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页 + usersAPI.PUT("/users/:id/points", h.AdminAdjustUserPoints) + usersAPI.GET("/users/:id/points/ledger", h.AdminUserPointLedger) // 站长内容档案(handler 内再校验 owner) usersAPI.GET("/users/:id", h.AdminGetUserAudit) usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts) diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index f89cb9a..415c2c1 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -57,6 +57,8 @@ type AdminUserItem struct { PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` Points int `json:"points"` + TotalPoints int `json:"total_points"` + Level int `json:"level"` // AfterFind 按 TotalPoints 计算 CreatedAt time.Time `json:"created_at"` LastSeenAt *time.Time `json:"last_seen_at"` Online bool `json:"online"` @@ -90,7 +92,7 @@ type AdminUserListQuery struct { Keyword string // 用户名 / 昵称 / 邮箱模糊匹配 Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举 Status string // "" 全部 | "banned" 已封禁 | "normal" 正常 - Sort string // 白名单列:id / role / status / points / last_login / counts / created_at / online + Sort string // 白名单列:id / role / status / points / total_points / last_login / counts / created_at / online Dir string // asc | desc;非法值按 desc } @@ -110,6 +112,8 @@ func adminUserOrderSQL(sort, dir string) string { return fmt.Sprintf("users.banned %s, users.id DESC", ord) case "points": return fmt.Sprintf("users.points %s, users.id DESC", ord) + case "total_points": + return fmt.Sprintf("users.total_points %s, users.id DESC", ord) case "created_at": return fmt.Sprintf("users.created_at %s, users.id DESC", ord) case "online": @@ -234,6 +238,8 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { Banned: u.Banned, CanManageMessages: u.CanManageMessages, Points: u.Points, + TotalPoints: u.TotalPoints, + Level: u.Level, CreatedAt: u.CreatedAt, LastSeenAt: u.LastSeenAt, } diff --git a/backend/service/badge.go b/backend/service/badge.go new file mode 100644 index 0000000..46a7b8a --- /dev/null +++ b/backend/service/badge.go @@ -0,0 +1,240 @@ +package service + +import ( + "errors" + "strings" + "time" + "unicode/utf8" + + "gorm.io/gorm" + + "github.com/freefire/jiang13-bbs/model" +) + +// 徽章业务错误 +var ( + ErrBadgeNotFound = errors.New("徽章不存在") + ErrBadgeNameExists = errors.New("徽章名称已存在") + ErrBadgeInvalid = errors.New("徽章参数无效") + ErrBadgeGranted = errors.New("用户已持有该徽章") + ErrBadgeUserGone = errors.New("用户不存在") +) + +const ( + MaxBadgeNameRunes = 16 + DefaultBadgeColor = "accent" +) + +// BadgeWithHolders 徽章 + 持有人数(后台管理列表用) +type BadgeWithHolders struct { + model.Badge + Holders int64 `json:"holders"` +} + +// BadgeService 管理员自定义徽章 +type BadgeService struct { + db *gorm.DB + notif *NotificationService + setting *SettingService +} + +func NewBadgeService(db *gorm.DB) *BadgeService { + return &BadgeService{db: db} +} + +// WithNotification 注入通知服务(授予时通知用户) +func (s *BadgeService) WithNotification(n *NotificationService) *BadgeService { + s.notif = n + return s +} + +// validateBadge 校验并规整名称/图标/颜色 +func validateBadge(name, icon, color string) (string, string, string, error) { + name = strings.TrimSpace(name) + if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > MaxBadgeNameRunes { + return "", "", "", ErrBadgeInvalid + } + icon = strings.TrimSpace(icon) + if utf8.RuneCountInString(icon) > 32 { + return "", "", "", ErrBadgeInvalid + } + color = strings.TrimSpace(color) + if color == "" { + color = DefaultBadgeColor + } + if !model.BadgeColors[color] { + return "", "", "", ErrBadgeInvalid + } + return name, icon, color, nil +} + +// List 全部徽章 + 持有数 +func (s *BadgeService) List() ([]BadgeWithHolders, error) { + var badges []model.Badge + if err := s.db.Order("id ASC").Find(&badges).Error; err != nil { + return nil, err + } + type cnt struct { + BadgeID uint + N int64 + } + var counts []cnt + if err := s.db.Model(&model.UserBadge{}). + Select("badge_id, count(1) AS n"). + Group("badge_id").Scan(&counts).Error; err != nil { + return nil, err + } + m := make(map[uint]int64, len(counts)) + for _, c := range counts { + m[c.BadgeID] = c.N + } + out := make([]BadgeWithHolders, 0, len(badges)) + for _, b := range badges { + out = append(out, BadgeWithHolders{Badge: b, Holders: m[b.ID]}) + } + return out, nil +} + +// Create 新建徽章 +func (s *BadgeService) Create(name, icon, color string) (*model.Badge, error) { + name, icon, color, err := validateBadge(name, icon, color) + if err != nil { + return nil, err + } + b := model.Badge{Name: name, Icon: icon, Color: color} + if err := s.db.Create(&b).Error; err != nil { + if isUniqueConflict(err) { + return nil, ErrBadgeNameExists + } + return nil, err + } + return &b, nil +} + +// Update 修改徽章(不影响已授予记录) +func (s *BadgeService) Update(id uint, name, icon, color string) (*model.Badge, error) { + name, icon, color, err := validateBadge(name, icon, color) + if err != nil { + return nil, err + } + var b model.Badge + if err := s.db.First(&b, id).Error; err != nil { + return nil, ErrBadgeNotFound + } + b.Name, b.Icon, b.Color = name, icon, color + if err := s.db.Save(&b).Error; err != nil { + if isUniqueConflict(err) { + return nil, ErrBadgeNameExists + } + return nil, err + } + return &b, nil +} + +// Delete 删除徽章并级联删除全部授予记录。 +// postgres 下 AutoMigrate 生成的 FK 为 NO ACTION,必须先删引用行再删徽章 +func (s *BadgeService) Delete(id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("badge_id = ?", id).Delete(&model.UserBadge{}).Error; err != nil { + return err + } + res := tx.Delete(&model.Badge{}, id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrBadgeNotFound + } + return nil + }) +} + +// Grant 授予徽章(幂等:已持有直接返回成功);同时发站内通知 +func (s *BadgeService) Grant(badgeID, userID, operatorID uint) error { + var badge model.Badge + if err := s.db.First(&badge, badgeID).Error; err != nil { + return ErrBadgeNotFound + } + var user model.User + if err := s.db.Select("id", "nickname").First(&user, userID).Error; err != nil { + return ErrBadgeUserGone + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&model.UserBadge{}). + Where("badge_id = ? AND user_id = ?", badgeID, userID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil // 幂等 + } + ub := model.UserBadge{BadgeID: badgeID, UserID: userID, AwardedBy: operatorID, AwardedAt: time.Now()} + return tx.Create(&ub).Error + }) + if err != nil { + return err + } + if s.notif != nil { + s.notif.Create(userID, operatorID, model.NotificationTypeBadge, 0, 0, "获得徽章「"+badge.Name+"」") + } + return nil +} + +// Revoke 撤销授予 +func (s *BadgeService) Revoke(badgeID, userID uint) error { + res := s.db.Where("badge_id = ? AND user_id = ?", badgeID, userID).Delete(&model.UserBadge{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrBadgeNotFound + } + return nil +} + +// BadgeHolder 持有者视图(后台授予面板用) +type BadgeHolder struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + AwardedAt time.Time `json:"awarded_at"` +} + +// Holders 某徽章全部持有者(含用户名) +func (s *BadgeService) Holders(badgeID uint) ([]BadgeHolder, error) { + var rows []BadgeHolder + if err := s.db.Table("user_badges"). + Select("user_badges.user_id, users.username, users.nickname, user_badges.awarded_at"). + Joins("JOIN users ON users.id = user_badges.user_id"). + Where("user_badges.badge_id = ? AND users.deleted_at IS NULL", badgeID). + Order("user_badges.awarded_at DESC"). + Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// ForUser 用户持有徽章(含定义与授予时间) +func (s *BadgeService) ForUser(userID uint) ([]model.UserBadge, error) { + var rows []model.UserBadge + if err := s.db.Preload("Badge"). + Where("user_id = ?", userID). + Order("awarded_at ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// isUniqueConflict 唯一索引冲突(名称重复等):postgres 报 duplicate key, +// sqlite(测试内存库)报 UNIQUE constraint failed +func isUniqueConflict(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return errors.Is(err, gorm.ErrDuplicatedKey) || + strings.Contains(msg, "duplicate key") || + strings.Contains(msg, "unique constraint") +} diff --git a/backend/service/badge_test.go b/backend/service/badge_test.go new file mode 100644 index 0000000..2cebd44 --- /dev/null +++ b/backend/service/badge_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "errors" + "testing" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newBadgeTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.Badge{}, &model.UserBadge{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestBadgeGrantIdempotent(t *testing.T) { + db := newBadgeTestDB(t) + s := NewBadgeService(db) + + user := model.User{Username: "alice", Password: "x"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + badge, err := s.Create(" 骨灰玩家 ", "", "") + if err != nil { + t.Fatalf("Create: %v", err) + } + if badge.Name != "骨灰玩家" || badge.Color != DefaultBadgeColor { + t.Fatalf("名称未规整/默认色不符: %+v", badge) + } + + if err := s.Grant(badge.ID, user.ID, 9); err != nil { + t.Fatalf("Grant: %v", err) + } + // 重复授予应幂等成功且不产生新行 + if err := s.Grant(badge.ID, user.ID, 9); err != nil { + t.Fatalf("Grant twice: %v", err) + } + var count int64 + db.Model(&model.UserBadge{}).Where("badge_id = ?", badge.ID).Count(&count) + if count != 1 { + t.Fatalf("授予记录=%d want 1", count) + } + + // 不存在的用户 + if err := s.Grant(badge.ID, 999, 9); !errors.Is(err, ErrBadgeUserGone) { + t.Fatalf("不存在用户应 ErrBadgeUserGone, got %v", err) + } + // 不存在的徽章 + if err := s.Grant(999, user.ID, 9); !errors.Is(err, ErrBadgeNotFound) { + t.Fatalf("不存在徽章应 ErrBadgeNotFound, got %v", err) + } + + // ForUser 带 Badge 定义 + rows, err := s.ForUser(user.ID) + if err != nil || len(rows) != 1 { + t.Fatalf("ForUser: %v rows=%d", err, len(rows)) + } + if rows[0].Badge.Name != "骨灰玩家" { + t.Fatalf("Preload Badge 失败: %+v", rows[0]) + } + + // Revoke 后再撤销 → NotFound + if err := s.Revoke(badge.ID, user.ID); err != nil { + t.Fatalf("Revoke: %v", err) + } + if err := s.Revoke(badge.ID, user.ID); !errors.Is(err, ErrBadgeNotFound) { + t.Fatal("重复撤销应 ErrBadgeNotFound") + } +} + +func TestBadgeDeleteCascadeAndListCount(t *testing.T) { + db := newBadgeTestDB(t) + s := NewBadgeService(db) + + u1 := model.User{Username: "u1", Password: "x"} + u2 := model.User{Username: "u2", Password: "x"} + if err := db.Create(&u1).Error; err != nil { + t.Fatalf("seed u1: %v", err) + } + if err := db.Create(&u2).Error; err != nil { + t.Fatalf("seed u2: %v", err) + } + b1, _ := s.Create("元老", "medal", "gold") + b2, _ := s.Create("萌新", "", "") + + if err := s.Grant(b1.ID, u1.ID, 9); err != nil { + t.Fatalf("Grant b1: %v", err) + } + if err := s.Grant(b1.ID, u2.ID, 9); err != nil { + t.Fatalf("Grant b1 u2: %v", err) + } + if err := s.Grant(b2.ID, u1.ID, 9); err != nil { + t.Fatalf("Grant b2: %v", err) + } + + // List 计数 + list, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 2 { + t.Fatalf("徽章数=%d want 2", len(list)) + } + for _, b := range list { + want := int64(2) + if b.ID == b2.ID { + want = 1 + } + if b.Holders != want { + t.Fatalf("徽章 %d 持有数=%d want %d", b.ID, b.Holders, want) + } + } + + // 删除级联:徽章与其授予记录一起消失 + if err := s.Delete(b1.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + var count int64 + db.Model(&model.UserBadge{}).Where("badge_id = ?", b1.ID).Count(&count) + if count != 0 { + t.Fatalf("删除后授予记录=%d want 0", count) + } + if err := s.Delete(b1.ID); !errors.Is(err, ErrBadgeNotFound) { + t.Fatal("重复删除应 ErrBadgeNotFound") + } + + // 名称唯一冲突 + if _, err := s.Create("萌新", "", ""); !errors.Is(err, ErrBadgeNameExists) { + t.Fatalf("重名应 ErrBadgeNameExists, got %v", err) + } +} + +func TestBadgeValidation(t *testing.T) { + s := NewBadgeService(newBadgeTestDB(t)) + + // 空名称 / 超长名称 / 非法颜色 + if _, err := s.Create(" ", "", ""); !errors.Is(err, ErrBadgeInvalid) { + t.Fatal("空名称应被拒绝") + } + long := make([]rune, MaxBadgeNameRunes+1) + for i := range long { + long[i] = '字' + } + if _, err := s.Create(string(long), "", ""); !errors.Is(err, ErrBadgeInvalid) { + t.Fatal("超长名称应被拒绝") + } + if _, err := s.Create("x", "", "pink"); !errors.Is(err, ErrBadgeInvalid) { + t.Fatal("色板外颜色应被拒绝") + } +} diff --git a/backend/service/board.go b/backend/service/board.go index ef065e8..007591e 100644 --- a/backend/service/board.go +++ b/backend/service/board.go @@ -455,7 +455,7 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) { var hotPosts []model.Post if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished). Order("(like_count + comment_count * 2) DESC, created_at DESC"). - Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil { + Limit(5).Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&hotPosts).Error; err != nil { return nil, err } data.Hot = toPostListItems(hotPosts) diff --git a/backend/service/chat.go b/backend/service/chat.go index 0c46c64..0c10008 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -223,7 +223,7 @@ func (s *ChatService) attachPeers(views []RoomView, viewerID uint) { return } var members []model.ChatRoomMember - s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Find(&members) + s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Preload("User.Badges.Badge").Find(&members) peerByRoom := make(map[uint]*model.User, len(members)) for i := range members { u := members[i].User @@ -717,7 +717,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch var list []model.ChatRoomMember err := s.db.Where("room_id = ?", roomID). Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC"). - Preload("User"). + Preload("User").Preload("User.Badges.Badge"). Find(&list).Error if err != nil { return nil, err @@ -1218,7 +1218,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp participantsByRoom := map[uint][]AdminChatParticipant{} if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 { var members []model.ChatRoomMember - s.db.Where("room_id IN ?", roomIDs).Preload("User").Find(&members) + s.db.Where("room_id IN ?", roomIDs).Preload("User").Preload("User.Badges.Badge").Find(&members) for i := range members { name := members[i].User.Nickname if name == "" { diff --git a/backend/service/checkin.go b/backend/service/checkin.go index 423b053..0c3fb28 100644 --- a/backend/service/checkin.go +++ b/backend/service/checkin.go @@ -2,13 +2,15 @@ package service import ( "errors" + "log" + "strconv" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) -// DailyCheckinPoints 每日签到固定积分 +// DailyCheckinPoints 每日签到默认基础分(可被设置 points_checkin_base 覆盖) const DailyCheckinPoints = 5 // maxStreakScan 连续天数最多向前回溯的天数 @@ -22,18 +24,38 @@ type CheckinStatus struct { CheckedToday bool `json:"checked_today"` Streak int `json:"streak"` // 连续签到天数 TotalPoints int `json:"total_points"` // 累计积分(可用余额) - TodayPoints int `json:"today_points"` // 今日签到可得积分 + TodayPoints int `json:"today_points"` // 今日签到可得基础积分(连续加成另行入账,流水可见) } // CheckinService 每日签到 type CheckinService struct { - db *gorm.DB + db *gorm.DB + setting *SettingService } func NewCheckinService(db *gorm.DB) *CheckinService { return &CheckinService{db: db} } +// WithSetting 注入站点设置(积分增长规则;未注入时用默认值) +func (s *CheckinService) WithSetting(setting *SettingService) *CheckinService { + s.setting = setting + return s +} + +// rules 读取积分规则;未注入或读取失败回落默认值(签到可用性优先于规则精度) +func (s *CheckinService) rules() PointsRules { + if s.setting == nil { + return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays} + } + rules, err := s.setting.PointsRules() + if err != nil { + log.Printf("[checkin] 读取积分规则失败,使用默认值: %v", err) + return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays} + } + return rules +} + // localDay 取本地时区零点日期 func localDay(t time.Time) time.Time { y, m, d := t.Date() @@ -53,9 +75,25 @@ func streakFrom(dates map[string]bool, anchor time.Time) int { return streak } +// streakDatesInTx 从事务连接查询 anchor 起回溯窗口内的签到日期集合 +func streakDatesInTx(tx *gorm.DB, userID uint, anchor time.Time) (map[string]bool, error) { + var dates []time.Time + if err := tx.Model(&model.Checkin{}). + Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)). + Pluck("checkin_date", &dates).Error; err != nil { + return nil, err + } + dateSet := make(map[string]bool, len(dates)) + for _, d := range dates { + dateSet[localDay(d).Format("2006-01-02")] = true + } + return dateSet, nil +} + // Status 查询用户签到状态 func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { today := localDay(time.Now()) + base := s.rules().CheckinBase var todayRow model.Checkin err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today). @@ -73,16 +111,10 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { if !checkedToday { anchor = today.AddDate(0, 0, -1) } - var dates []time.Time - if err := s.db.Model(&model.Checkin{}). - Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)). - Pluck("checkin_date", &dates).Error; err != nil { + dateSet, err := streakDatesInTx(s.db, userID, anchor) + if err != nil { return nil, err } - dateSet := make(map[string]bool, len(dates)) - for _, d := range dates { - dateSet[localDay(d).Format("2006-01-02")] = true - } var total int64 if err := s.db.Model(&model.User{}). @@ -95,13 +127,15 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { CheckedToday: checkedToday, Streak: streakFrom(dateSet, anchor), TotalPoints: int(total), - TodayPoints: DailyCheckinPoints, + TodayPoints: base, }, nil } -// CheckIn 执行今日签到(每日一条,唯一索引兜底并发) +// CheckIn 执行今日签到(每日一条,唯一索引兜底并发)。 +// 基础分与连续签到里程碑加成在同一事务内入账(加成规则可配置:每满 N 天额外 +X)。 func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) { today := localDay(time.Now()) + rules := s.rules() var status *CheckinStatus err := s.db.Transaction(func(tx *gorm.DB) error { @@ -117,15 +151,28 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) { row := model.Checkin{ UserID: userID, CheckinDate: today, - Points: DailyCheckinPoints, + Points: rules.CheckinBase, } if err := tx.Create(&row).Error; err != nil { return err } // 同步入账到用户积分余额 - if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil { + if _, err := CreditTx(tx, userID, rules.CheckinBase, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil { return err } + // 连续签到里程碑加成(每满 N 天额外 +X;N=0 或加成=0 时关闭) + if rules.StreakEveryDays > 0 && rules.StreakBonus > 0 { + dateSet, err := streakDatesInTx(tx, userID, today) + if err != nil { + return err + } + if streak := streakFrom(dateSet, today); streak > 0 && streak%rules.StreakEveryDays == 0 { + if _, err := CreditTx(tx, userID, rules.StreakBonus, model.PointReasonStreakBonus, "checkin", row.ID, + "连续签到 "+strconv.Itoa(streak)+" 天奖励"); err != nil { + return err + } + } + } return nil }) if err != nil { diff --git a/backend/service/comment.go b/backend/service/comment.go index 464025b..d47769c 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -23,12 +23,19 @@ var ( // CommentService 评论服务 type CommentService struct { db *gorm.DB + setting *SettingService } func NewCommentService(db *gorm.DB) *CommentService { return &CommentService{db: db} } +// WithSetting 注入站点设置(回复奖励规则;未注入时奖励关闭) +func (s *CommentService) WithSetting(setting *SettingService) *CommentService { + s.setting = setting + return s +} + // CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略 type CommentNode struct { model.Comment @@ -84,7 +91,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A if err := base.Session(&gorm.Session{}). Where("parent_id IS NULL"). Order("created_at ASC"). - Preload("User"). + Preload("User").Preload("User.Badges.Badge"). Offset((page - 1) * size). Limit(size). Find(&roots).Error; err != nil { @@ -100,7 +107,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A } replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor) var replies []model.Comment - if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil { + if err := replyQ.Order("created_at ASC").Preload("User").Preload("User.Badges.Badge").Find(&replies).Error; err != nil { return nil, 0, 0, err } @@ -355,9 +362,19 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u // 仅已发布评论立即计入评论数;待审评论通过审核时才 +1 if comment.Status == model.ContentStatusPublished { s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) + // 回复奖励(可配置;待审不发且过审不补发,防拒审刷分) + if s.setting != nil { + if rules, err := s.setting.PointsRules(); err == nil { + s.db.Transaction(func(tx *gorm.DB) error { + RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap, + model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title) + return nil + }) + } + } } // 预加载用户 - s.db.Preload("User").First(comment, comment.ID) + s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID) return comment, parent, nil } @@ -648,7 +665,7 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st return nil, err } - if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil { + if err := s.db.Preload("User").Preload("User.Badges.Badge").First(&comment, comment.ID).Error; err != nil { return nil, err } node := toCommentNode(comment, nil, false, userID, nil) diff --git a/backend/service/overview.go b/backend/service/overview.go index 0735831..db67099 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -181,7 +181,7 @@ func (s *OverviewService) Get() (*OverviewData, error) { var hotPosts []model.Post if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished). Order("(like_count + comment_count * 2) DESC, created_at DESC"). - Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil { + Limit(5).Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&hotPosts).Error; err != nil { return nil, err } data.Hot = toPostListItems(hotPosts) diff --git a/backend/service/points.go b/backend/service/points.go index c2bed31..431e45a 100644 --- a/backend/service/points.go +++ b/backend/service/points.go @@ -2,6 +2,7 @@ package service import ( "errors" + "log" "time" "github.com/freefire/jiang13-bbs/model" @@ -32,7 +33,8 @@ func (s *PointsService) Balance(userID uint) (int, error) { return u.Points, nil } -// CreditTx 在事务内入账 +// CreditTx 在事务内入账;入账同时累加累计获得积分(经验值,等级依据)。 +// bounty_refund 是悬赏托管退回(自己托给自己的钱),不计入经验,与启动回填口径一致。 func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { if delta <= 0 { return 0, ErrInvalidPoints @@ -43,7 +45,13 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID return 0, err } bal := u.Points + delta - if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil { + updates := map[string]interface{}{ + "points": bal, + } + if reason != model.PointReasonBountyRefund { + updates["total_points"] = gorm.Expr("total_points + ?", delta) + } + if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { return 0, err } if err := tx.Create(&model.PointLedger{ @@ -60,6 +68,31 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID return bal, nil } +// RewardIfConfigured 可配置奖励入账:规则分>0 且当日该 reason 正入账笔数未超 cap 时发放; +// cap<=0 不限。计数按本地日统计 point_ledgers 同 reason 正入账笔数。 +// 并发下允许低概率超发 1-2 笔,论坛场景可接受(不做唯一索引级严格)。 +// 发放失败仅记日志不阻断主流程(奖励非关键路径)。 +func RewardIfConfigured(tx *gorm.DB, userID uint, points, cap int, reason, refType string, refID uint, note string) { + if points <= 0 { + return + } + if cap > 0 { + var used int64 + if err := tx.Model(&model.PointLedger{}). + Where("user_id = ? AND reason = ? AND delta > 0 AND created_at >= ?", userID, reason, localDay(time.Now())). + Count(&used).Error; err != nil { + log.Printf("[points] 奖励上限检查失败(忽略): user=%d reason=%s err=%v", userID, reason, err) + return + } + if used >= int64(cap) { + return + } + } + if _, err := CreditTx(tx, userID, points, reason, refType, refID, note); err != nil { + log.Printf("[points] 奖励入账失败(忽略): user=%d reason=%s err=%v", userID, reason, err) + } +} + // DebitTx 在事务内扣款 func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { if delta <= 0 { @@ -291,6 +324,11 @@ func transferEnds(a, b model.PointLedger) (fromID, toID uint) { // Ledger 分页查询本人积分流水 func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) { + return ledgerPage(s.db, userID, page, size) +} + +// ledgerPage 积分流水分页查询(本人/管理端共用口径:id DESC,size 1..50 缺省 20) +func ledgerPage(db *gorm.DB, userID uint, page, size int) ([]LedgerItem, int64, error) { if page < 1 { page = 1 } @@ -298,7 +336,7 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64 size = 20 } var total int64 - q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID) + q := db.Model(&model.PointLedger{}).Where("user_id = ?", userID) if err := q.Count(&total).Error; err != nil { return nil, 0, err } @@ -316,6 +354,33 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64 return out, total, nil } +// AdminUserLedger 管理端查询任意用户积分流水 +func (s *PointsService) AdminUserLedger(userID uint, page, size int) ([]LedgerItem, int64, error) { + return ledgerPage(s.db, userID, page, size) +} + +// AdminAdjustPoints 管理员手动调整积分:加分复用 CreditTx(同步累加累计获得→可升级), +// 扣分复用 DebitTx(仅扣余额、不降级);reason 统一 admin_adjust,note 为调整理由。 +// 返回调整后余额、累计获得与等级。 +func (s *PointsService) AdminAdjustPoints(userID uint, delta int, note string) (bal, total, level int, err error) { + err = s.db.Transaction(func(tx *gorm.DB) error { + if delta > 0 { + bal, err = CreditTx(tx, userID, delta, model.PointReasonAdminAdjust, "admin", 0, note) + } else { + bal, err = DebitTx(tx, userID, -delta, model.PointReasonAdminAdjust, "admin", 0, note) + } + return err + }) + if err != nil { + return 0, 0, 0, err + } + var u model.User + if err := s.db.Select("id", "total_points").First(&u, userID).Error; err != nil { + return 0, 0, 0, err + } + return bal, u.TotalPoints, model.LevelOf(u.TotalPoints), nil +} + // DeltaDay 单日积分发放与消耗 type DeltaDay struct { Date string `json:"date"` diff --git a/backend/service/post.go b/backend/service/post.go index 179df91..55e8f9b 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -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 diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index aa9f191..ce85d3e 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -723,7 +723,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view return nil } var c model.Comment - err := s.db.Unscoped().Preload("User"). + err := s.db.Unscoped().Preload("User").Preload("User.Badges.Badge"). Where("id = ? AND post_id = ?", commentID, postID). First(&c).Error if err != nil { @@ -1039,7 +1039,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana // VotePoll 投票 func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*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 } if model.NormalizePostType(post.PostType) != model.PostTypePoll { @@ -1286,7 +1286,7 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) 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) return s.detailAfterInteract(post, userID) } @@ -1498,7 +1498,7 @@ func StartTypedPostSettler(s *PostService) { func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, 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 } if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) { diff --git a/backend/service/setting.go b/backend/service/setting.go index 9735aa1..6bc2f88 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "errors" "regexp" "strconv" @@ -57,6 +58,22 @@ const ( SettingKeyAnalyticsEnabled = "analytics_enabled" // SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90 SettingKeyAnalyticsRetentionDays = "analytics_retention_days" + + // 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值) + SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5 + SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7 + SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0 + SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0 + SettingKeyPointsPostDailyCap = "points_post_daily_cap" // 发帖奖励每日上限笔数;0=不限;缺行=0 + SettingKeyPointsReplyReward = "points_reply_reward" // 回复奖励;0=关闭;缺行=0 + SettingKeyPointsReplyDailyCap = "points_reply_daily_cap" // 回复奖励每日上限笔数;0=不限;缺行=0 + SettingKeyPointsRecommendReward = "points_recommend_reward" // 帖子被推荐奖励(每帖仅一次);0=关闭;缺行=0 + + // SettingKeyLevelsConfig 用户等级体系配置(JSON []model.LevelDef:阈值+色板 key);缺行=默认 7 级 + SettingKeyLevelsConfig = "levels_config" + + // SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开) + SettingKeyLevelsFx = "levels_fx" ) const ( @@ -82,6 +99,10 @@ const ( DefaultAnalyticsRetentionDays = 90 MinAnalyticsRetentionDays = 7 MaxAnalyticsRetentionDays = 365 + + DefaultPointsCheckinBase = 5 + DefaultPointsStreakEveryDays = 7 + MaxPointsRule = 100 // 各积分规则值/上限的统一上界 ) // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) @@ -143,6 +164,21 @@ type PublicSiteSettings struct { BrandLogoSize string `json:"brand_logo_size"` BrandLogoFit string `json:"brand_logo_fit"` FooterLinks []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"` + + // 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端按累计获得积分计算 + Levels []model.LevelDef `json:"levels"` + // LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色) + LevelsFx bool `json:"levels_fx"` } // SettingService 站点级键值设置 @@ -308,6 +344,22 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { } out.AllowMessages = messages + rules, err := s.PointsRules() + if err != nil { + return out, err + } + out.PointsCheckinBase = rules.CheckinBase + out.PointsStreakEveryDays = rules.StreakEveryDays + out.PointsStreakBonus = rules.StreakBonus + out.PointsPostReward = rules.PostReward + out.PointsPostDailyCap = rules.PostDailyCap + out.PointsReplyReward = rules.ReplyReward + out.PointsReplyDailyCap = rules.ReplyDailyCap + out.PointsRecommendReward = rules.RecommendReward + + out.Levels = s.Levels() + out.LevelsFx = s.LevelsFx() + hours, err := s.PostCooldownHours() if err != nil { return out, err @@ -932,3 +984,173 @@ func (s *SettingService) SetAnalyticsRetentionDays(days int) error { } return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days)) } + +// PointsRules 积分增长规则聚合(全部 0-100 裁剪,非法/缺行取默认) +type PointsRules struct { + CheckinBase int + StreakEveryDays int + StreakBonus int + PostReward int + PostDailyCap int + ReplyReward int + ReplyDailyCap int + RecommendReward int +} + +// getPointsValue 读取单个积分规则键(0-100 裁剪,非法/缺行取 def) +func (s *SettingService) getPointsValue(key string, def int) (int, error) { + v, found, err := s.getValue(key) + if err != nil { + return def, err + } + if !found { + return def, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < 0 { + return def, nil + } + if n > MaxPointsRule { + return MaxPointsRule, nil + } + return n, nil +} + +// setPointsValue 持久化积分规则;等于默认值时删键保持「缺行=默认」 +func (s *SettingService) setPointsValue(key string, n, def int) error { + if n < 0 || n > MaxPointsRule { + return ErrInvalidSiteSetting + } + if n == def { + return s.deleteKey(key) + } + return s.putValue(key, strconv.Itoa(n)) +} + +// PointsRules 一次读取全部积分增长规则 +func (s *SettingService) PointsRules() (PointsRules, error) { + rules := PointsRules{ + CheckinBase: DefaultPointsCheckinBase, + StreakEveryDays: DefaultPointsStreakEveryDays, + } + keys := []struct { + key string + dst *int + }{ + {SettingKeyPointsCheckinBase, &rules.CheckinBase}, + {SettingKeyPointsStreakEveryDays, &rules.StreakEveryDays}, + {SettingKeyPointsStreakBonus, &rules.StreakBonus}, + {SettingKeyPointsPostReward, &rules.PostReward}, + {SettingKeyPointsPostDailyCap, &rules.PostDailyCap}, + {SettingKeyPointsReplyReward, &rules.ReplyReward}, + {SettingKeyPointsReplyDailyCap, &rules.ReplyDailyCap}, + {SettingKeyPointsRecommendReward, &rules.RecommendReward}, + } + for _, it := range keys { + n, err := s.getPointsValue(it.key, *it.dst) + if err != nil { + return rules, err + } + *it.dst = n + } + return rules, nil +} + +func (s *SettingService) SetPointsCheckinBase(n int) error { + return s.setPointsValue(SettingKeyPointsCheckinBase, n, DefaultPointsCheckinBase) +} + +func (s *SettingService) SetPointsStreakEveryDays(n int) error { + return s.setPointsValue(SettingKeyPointsStreakEveryDays, n, DefaultPointsStreakEveryDays) +} + +func (s *SettingService) SetPointsStreakBonus(n int) error { + return s.setPointsValue(SettingKeyPointsStreakBonus, n, 0) +} + +func (s *SettingService) SetPointsPostReward(n int) error { + return s.setPointsValue(SettingKeyPointsPostReward, n, 0) +} + +func (s *SettingService) SetPointsPostDailyCap(n int) error { + return s.setPointsValue(SettingKeyPointsPostDailyCap, n, 0) +} + +func (s *SettingService) SetPointsReplyReward(n int) error { + return s.setPointsValue(SettingKeyPointsReplyReward, n, 0) +} + +func (s *SettingService) SetPointsReplyDailyCap(n int) error { + return s.setPointsValue(SettingKeyPointsReplyDailyCap, n, 0) +} + +func (s *SettingService) SetPointsRecommendReward(n int) error { + return s.setPointsValue(SettingKeyPointsRecommendReward, n, 0) +} + +// levelColorPalette 等级可用色板 key(与徽章色板一致 + neutral 中性色) +var levelColorPalette = map[string]bool{ + "neutral": true, + "gold": true, + "accent": true, + "ok": true, + "danger": true, + "info": true, + "purple": true, + "teal": true, +} + +// Levels 等级体系配置;缺行/解析失败/校验不过回落默认表 +func (s *SettingService) Levels() []model.LevelDef { + v, found, err := s.getValue(SettingKeyLevelsConfig) + if err != nil || !found || strings.TrimSpace(v) == "" { + return model.DefaultLevelDefs() + } + var defs []model.LevelDef + if err := json.Unmarshal([]byte(v), &defs); err != nil { + return model.DefaultLevelDefs() + } + if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) { + return model.DefaultLevelDefs() + } + return defs +} + +// SetLevels 校验并保存等级配置,同时刷新进程内快照(LevelOf 立即生效) +func (s *SettingService) SetLevels(defs []model.LevelDef) error { + if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) { + return ErrInvalidSiteSetting + } + b, err := json.Marshal(defs) + if err != nil { + return err + } + if err := s.putValue(SettingKeyLevelsConfig, string(b)); err != nil { + return err + } + model.ApplyLevelDefs(defs) + return nil +} + +// LevelsFx 等级特效动画总开关;缺行视为开启 +func (s *SettingService) LevelsFx() bool { + v, found, err := s.getValue(SettingKeyLevelsFx) + if err != nil { + return true + } + return parseBoolDefaultTrue(v, found) +} + +// SetLevelsFx 保存等级特效动画总开关(开=删行回落默认,关=显式存 false) +func (s *SettingService) SetLevelsFx(on bool) error { + return s.setBoolDefaultTrue(SettingKeyLevelsFx, on) +} + +func levelColorsValid(defs []model.LevelDef) bool { + for _, d := range defs { + if !levelColorPalette[d.Color] { + return false + } + } + return true +} diff --git a/frontend/app/admin/badges/BadgesClient.tsx b/frontend/app/admin/badges/BadgesClient.tsx new file mode 100644 index 0000000..8ee27e9 --- /dev/null +++ b/frontend/app/admin/badges/BadgesClient.tsx @@ -0,0 +1,441 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Pencil, Plus, Trash2, Users } from "lucide-react"; +import Avatar from "@/components/Avatar"; +import BadgeChip from "@/components/BadgeChip"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import Modal from "@/components/Modal"; +import { BADGE_ICONS } from "@/lib/badgeIcons"; +import { toast } from "@/lib/toast"; +import { + apiAdminBadgeHolders, + apiAdminCreateBadge, + apiAdminDeleteBadge, + apiAdminGrantBadge, + apiAdminListBadges, + apiAdminListUsers, + apiAdminRevokeBadge, + apiAdminUpdateBadge, + type BadgeHolder, + type BadgeInput, + type BadgeWithHolders, +} from "@/lib/api"; + +/** 与后端 model.BadgeColors 同步的色板(CSS token 名) */ +const BADGE_COLORS = ["gold", "accent", "ok", "danger", "info", "purple", "teal"] as const; +const MAX_NAME_RUNES = 16; + +const runeCount = (s: string) => [...s].length; + +export default function BadgesClient() { + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(true); + + // 编辑/新建表单 + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ name: "", icon: "medal", color: "accent" }); + const [saving, setSaving] = useState(false); + + // 删除确认 + const [deleting, setDeleting] = useState(null); + const [deletingBusy, setDeletingBusy] = useState(false); + + // 持有者面板 + const [holdersFor, setHoldersFor] = useState(null); + const [holders, setHolders] = useState([]); + const [holdersLoading, setHoldersLoading] = useState(false); + const [userQuery, setUserQuery] = useState(""); + const [searchResults, setSearchResults] = useState<{ id: number; nickname: string; username: string; avatar: string }[]>([]); + const [searching, setSearching] = useState(false); + const [grantingId, setGrantingId] = useState(null); + + const reload = useCallback(async () => { + try { + const res = await apiAdminListBadges(); + setBadges(res.badges ?? []); + } catch (e) { + toast(e instanceof Error ? e.message : "获取徽章列表失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const openCreate = () => { + setForm({ name: "", icon: "medal", color: "accent" }); + setEditing(null); + setCreating(true); + }; + + const openEdit = (b: BadgeWithHolders) => { + setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" }); + setCreating(false); + setEditing(b); + }; + + const closeForm = () => { + setCreating(false); + setEditing(null); + }; + + const saveForm = async () => { + if (saving) return; + if (runeCount(form.name.trim()) < 1 || runeCount(form.name.trim()) > MAX_NAME_RUNES) { + toast(`名称需 1–${MAX_NAME_RUNES} 字`); + return; + } + setSaving(true); + try { + if (editing) { + await apiAdminUpdateBadge(editing.id, form); + toast("徽章已更新", "ok"); + } else { + await apiAdminCreateBadge(form); + toast("徽章已创建", "ok"); + } + closeForm(); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + const doDelete = async () => { + if (!deleting || deletingBusy) return; + setDeletingBusy(true); + try { + await apiAdminDeleteBadge(deleting.id); + toast("徽章已删除", "ok"); + setDeleting(null); + if (holdersFor?.id === deleting.id) setHoldersFor(null); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败"); + } finally { + setDeletingBusy(false); + } + }; + + const openHolders = async (b: BadgeWithHolders) => { + setHoldersFor(b); + setUserQuery(""); + setSearchResults([]); + setHoldersLoading(true); + try { + const res = await apiAdminBadgeHolders(b.id); + setHolders(res.holders ?? []); + } catch (e) { + toast(e instanceof Error ? e.message : "获取持有者失败"); + setHolders([]); + } finally { + setHoldersLoading(false); + } + }; + + const searchUsers = async () => { + const q = userQuery.trim(); + if (!q || searching) return; + setSearching(true); + try { + const res = await apiAdminListUsers({ q, size: 8 }); + setSearchResults( + (res.users ?? []).map((u) => ({ + id: u.id, + nickname: u.nickname, + username: u.username, + avatar: u.avatar, + })), + ); + } catch (e) { + toast(e instanceof Error ? e.message : "搜索用户失败"); + } finally { + setSearching(false); + } + }; + + const grant = async (userId: number) => { + if (!holdersFor || grantingId != null) return; + setGrantingId(userId); + try { + await apiAdminGrantBadge(holdersFor.id, userId); + toast("已授予", "ok"); + await openHolders(holdersFor); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "授予失败"); + } finally { + setGrantingId(null); + } + }; + + const revoke = async (userId: number) => { + if (!holdersFor) return; + try { + await apiAdminRevokeBadge(holdersFor.id, userId); + toast("已撤销", "ok"); + await openHolders(holdersFor); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "撤销失败"); + } + }; + + const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]); + + return ( +
+
+
+

+ 徽章管理 +

+

+ 管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。 +

+
+ +
+ + {loading ? ( +

加载徽章列表…

+ ) : badges.length === 0 ? ( +
+

还没有徽章,点击右上角“新建徽章”开始。

+
+ ) : ( +
+ {badges.map((b, i) => ( +
+ + + {b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有 + + + + + +
+ ))} +
+ )} + + {/* 持有者面板 */} + setHoldersFor(null)} + maxWidthClass="max-w-lg" + > +
+ setUserQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void searchUsers(); + }} + placeholder="输入用户名或昵称搜索" + className="flex-1 min-w-0" + /> + +
+ {searchResults.length > 0 && ( +
+

搜索结果

+
+ {searchResults.map((u) => ( +
+ + + {u.nickname || u.username} + @{u.username} + + + {holdersHeldIds.has(u.id) ? ( + 已持有 + ) : ( + + )} +
+ ))} +
+
+ )} +

当前持有者({holders.length})

+ {holdersLoading ? ( +

加载持有者…

+ ) : holders.length === 0 ? ( +

暂无人持有。

+ ) : ( +
+ {holders.map((h) => ( +
+ + {h.nickname || h.username} + @{h.username} + + {new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予 + + +
+ ))} +
+ )} +
+ + {/* 新建/编辑表单 */} + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + maxLength={MAX_NAME_RUNES * 2} + placeholder="如:元老、技术之星" + /> +
+
+ +
+ {Object.keys(BADGE_ICONS).map((key) => { + const Icon = BADGE_ICONS[key]; + const active = form.icon === key; + return ( + + ); + })} +
+
+
+ +
+ {BADGE_COLORS.map((c) => { + const active = form.color === c; + return ( + + ); + })} +
+
+
+

预览

+ +
+
+
+ + +
+
+ + {/* 删除确认 */} + setDeleting(null)} + onConfirm={() => void doDelete()} + /> +
+ ); +} diff --git a/frontend/app/admin/badges/page.tsx b/frontend/app/admin/badges/page.tsx new file mode 100644 index 0000000..6655b28 --- /dev/null +++ b/frontend/app/admin/badges/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import BadgesClient from "./BadgesClient"; +import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; +import { isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../Forbidden"; + +export const metadata: Metadata = { + title: "徽章管理", +}; + +// 徽章管理仅超管/站长可用(与站点设置同权限;后端路由另有守卫) +export default async function AdminBadgesPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + + return ; +} diff --git a/frontend/app/admin/economy/page.tsx b/frontend/app/admin/economy/page.tsx deleted file mode 100644 index f60ebbd..0000000 --- a/frontend/app/admin/economy/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Metadata } from "next"; -import { cookies } from "next/headers"; -import { fetchAdminEconomyStats } from "@/lib/api"; -import { authCookieHeader } from "@/lib/cookies"; -import { getMeCached } from "@/lib/serverData"; -import { isAdminOrAbove } from "@/lib/roles"; -import Forbidden from "../Forbidden"; -import EconomyClient from "./EconomyClient"; - -export const metadata: Metadata = { - title: "积分看板", -}; - -export default async function AdminEconomyPage() { - const cookie = authCookieHeader(await cookies()); - const me = await getMeCached(cookie || undefined); - if (!me.user || !isAdminOrAbove(me.user.role)) { - return ; - } - - let stats: Awaited> | null = null; - try { - stats = await fetchAdminEconomyStats(cookie); - } catch { - stats = null; - } - - if (!stats) { - return ( -
-

暂时无法加载积分数据

-
- ); - } - - return ; -} diff --git a/frontend/app/admin/levels/LevelsAdmin.tsx b/frontend/app/admin/levels/LevelsAdmin.tsx new file mode 100644 index 0000000..321f2f2 --- /dev/null +++ b/frontend/app/admin/levels/LevelsAdmin.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Plus, RotateCcw, Trash2 } from "lucide-react"; +import { AdminStepper, AdminSwitch } from "@/components/admin"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import useUnsavedGuard from "@/hooks/useUnsavedGuard"; +import { apiGetSettings, apiUpdateSiteSettings } from "@/lib/api"; +import { + DEFAULT_LEVEL_DEFS, + LEVEL_BADGE_CLASS, + LEVEL_COLOR_KEYS, + LEVEL_COLOR_TOKENS, + MAX_LEVEL_COUNT, + splitBackground, + type LevelDef, +} from "@/lib/levels"; +import { toast } from "@/lib/toast"; + +const cloneDefs = (defs: LevelDef[]): LevelDef[] => + defs.map((d) => ({ threshold: d.threshold, color: d.color })); + +const sameDefs = (a: LevelDef[] | null, b: LevelDef[] | null) => { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + return a.every((d, i) => d.threshold === b[i].threshold && d.color === b[i].color); +}; + +/** 等级管理:配置等级数量(2–12)、每级所需累计积分与前景/背景配色、特效开关 */ +export default function LevelsAdmin() { + const [form, setForm] = useState(null); // null = 加载中 + const [saved, setSaved] = useState(null); + const [fx, setFx] = useState(true); + const [savedFx, setSavedFx] = useState(true); + const [resetOpen, setResetOpen] = useState(false); + const [saving, setSaving] = useState(false); + const dirty = useMemo( + () => !sameDefs(form, saved) || fx !== savedFx, + [form, saved, fx, savedFx], + ); + const leave = useUnsavedGuard(dirty); + + useEffect(() => { + let alive = true; + apiGetSettings() + .then((s) => { + if (!alive) return; + const defs = cloneDefs(s.levels); + setForm(defs); + setSaved(defs); + setFx(s.levels_fx); + setSavedFx(s.levels_fx); + }) + .catch((e) => { + toast(e instanceof Error ? e.message : "获取等级配置失败"); + if (alive) setForm([]); + }); + return () => { + alive = false; + }; + }, []); + + const setThreshold = (i: number, v: number) => + setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, threshold: v } : d)) : f)); + const setColor = (i: number, color: string) => + setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, color } : d)) : f)); + const addLevel = () => + setForm((f) => { + if (!f || f.length >= MAX_LEVEL_COUNT) return f; + const last = f[f.length - 1]; + return [...f, { threshold: (last?.threshold ?? 0) + 100, color: "neutral" }]; + }); + const removeLast = () => + setForm((f) => (f && f.length > 2 ? f.slice(0, -1) : f)); + + const save = async () => { + if (saving || !dirty || !form) return; + if (form.length < 2) { + toast("至少保留 2 个等级"); + return; + } + if (form[0].threshold !== 0) { + toast("Lv.1 所需积分须为 0"); + return; + } + for (let i = 1; i < form.length; i++) { + if (!(form[i].threshold > form[i - 1].threshold)) { + toast(`Lv.${i + 1} 所需积分须大于上一级`); + return; + } + } + setSaving(true); + try { + const res = await apiUpdateSiteSettings({ levels: form, levels_fx: fx }); + const defs = cloneDefs(res.levels); + setForm(defs); + setSaved(defs); + setSavedFx(fx); + toast("等级配置已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + // 重置:仅回填默认等级表(2–12 约束内),需再点保存才生效 + const resetToDefault = () => { + if (!form) return; + setForm(cloneDefs(DEFAULT_LEVEL_DEFS)); + setResetOpen(false); + toast("已恢复默认等级表,点击保存生效", "ok"); + }; + + return ( +
+
+

+ 等级管理 +

+

+ 用户等级由累计获得积分决定,达到某级阈值即晋升。可配置等级数量(2–{MAX_LEVEL_COUNT}{" "} + 级)、每级所需积分与配色;等级徽章仅显示 Lv.n,不弹悬浮提示。 +

+
+ + {!form ? ( +

加载等级配置…

+ ) : form.length === 0 ? ( +
+

等级配置加载失败,请刷新页面重试。

+
+ ) : ( +
+ {form.map((d, i) => { + const n = i + 1; + const tokens = LEVEL_COLOR_TOKENS[d.color] ?? LEVEL_COLOR_TOKENS.neutral; + const textBg = splitBackground(tokens.text ?? "none"); + const isLast = i === form.length - 1; + return ( +
+ {/* 预览:与 LevelBadge 同构(外层底色 + .lv-text 文字层),实时反映未保存的修改 */} + + + + Lv.{n} + + + +
+

+ 所需累计积分 +

+ setThreshold(i, v)} + min={0} + max={1000000} + unit="分" + ariaLabel={`Lv.${n} 所需积分`} + disabled={saving || n === 1} + /> +
+
+

+ 配色(前景/背景) +

+
+ {LEVEL_COLOR_KEYS.map((c) => { + const t = LEVEL_COLOR_TOKENS[c]; + const active = d.color === c; + return ( +
+
+ + {isLast && ( + + )} +
+ ); + })} + {/* 添加新等级:整宽虚线条目,与上方项同宽 */} + {form.length < MAX_LEVEL_COUNT ? ( + + ) : ( +
+ 已达 {MAX_LEVEL_COUNT} 级上限 +
+ )} +
+ )} + +
+ + +
+ setFx((v) => !v)} + label="特效动画" + /> +
+ + {dirty + ? "有未保存的修改" + : "已保存的配色在页面刷新后生效,等级数值即时按新阈值重算"} + +
+ + setResetOpen(false)} + onConfirm={resetToDefault} + /> + + +
+ ); +} diff --git a/frontend/app/admin/levels/page.tsx b/frontend/app/admin/levels/page.tsx new file mode 100644 index 0000000..6799727 --- /dev/null +++ b/frontend/app/admin/levels/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import LevelsAdmin from "./LevelsAdmin"; +import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; +import { isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../Forbidden"; + +export const metadata: Metadata = { + title: "等级管理", +}; + +// 等级管理仅超管/站长可用(与徽章管理同权限;后端路由另有守卫) +export default async function AdminLevelsPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + + return ; +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index f316a31..98a5a3c 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -154,9 +154,9 @@ export default async function AdminDashboardPage() { description={`${dateLabel} · 站点管理`} actions={ canEconomy ? ( - + - 积分看板 + 积分管理 ) : undefined } diff --git a/frontend/app/admin/economy/EconomyClient.tsx b/frontend/app/admin/points/EconomyOverview.tsx similarity index 100% rename from frontend/app/admin/economy/EconomyClient.tsx rename to frontend/app/admin/points/EconomyOverview.tsx diff --git a/frontend/app/admin/points/PointsTabs.tsx b/frontend/app/admin/points/PointsTabs.tsx new file mode 100644 index 0000000..b1a133f --- /dev/null +++ b/frontend/app/admin/points/PointsTabs.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AdminSegmented } from "@/components/admin"; +import type { EconomyStats, PublicSettings } from "@/lib/api"; +import EconomyOverview from "./EconomyOverview"; +import EconomySettings from "../settings/EconomySettings"; +import UserPointsTab from "./UserPointsTab"; + +type TabKey = "overview" | "rules" | "users"; + +const TAB_OPTIONS = [ + { key: "overview", label: "经济总览" }, + { key: "rules", label: "积分规则" }, + { key: "users", label: "用户积分" }, +]; + +/** + * 积分管理页标签壳:经济总览(管理员及以上)/ 积分规则 + 用户积分(超管/站长)。 + * tab 记在 URL query(?tab=)便于刷新/分享保持;三个面板保持挂载、用 display 切换, + * 避免切标签丢失规则的未保存修改与用户列表状态。 + */ +export default function PointsTabs({ + stats, + settings, + canManage, +}: { + stats: EconomyStats | null; + settings: PublicSettings | null; + canManage: boolean; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + const queryTab = searchParams.get("tab"); + const [tab, setTab] = useState(() => { + const t = queryTab; + if (canManage && (t === "rules" || t === "users")) return t; + return "overview"; + }); + + const switchTab = useCallback( + (next: string) => { + setTab(next as TabKey); + const params = new URLSearchParams(searchParams.toString()); + if (next === "overview") params.delete("tab"); + else params.set("tab", next); + const qs = params.toString(); + router.replace(qs ? `?${qs}` : "?", { scroll: false }); + }, + [router, searchParams] + ); + + const options = canManage + ? TAB_OPTIONS + : TAB_OPTIONS.filter((o) => o.key === "overview"); + + return ( +
+ {canManage ? ( +
+ switchTab(String(v))} + ariaLabel="积分管理标签" + /> +
+ ) : null} + + {/* 各面板保持挂载(display 切换):规则未保存修改与用户列表状态不因切标签丢失 */} +
+ {stats ? ( + + ) : ( +
+

暂时无法加载积分数据

+
+ )} +
+ {canManage ? ( + <> +
+ {settings ? : null} +
+
+ +
+ + ) : null} +
+ ); +} diff --git a/frontend/app/admin/points/UserPointsTab.tsx b/frontend/app/admin/points/UserPointsTab.tsx new file mode 100644 index 0000000..46149c4 --- /dev/null +++ b/frontend/app/admin/points/UserPointsTab.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { ChevronDown, ChevronUp, ChevronsUpDown, History, SlidersHorizontal, Users as UsersIcon } from "lucide-react"; +import { + apiAdminAdjustUserPoints, + apiAdminListUsers, + apiAdminUserPointLedger, + type AdminUser, + type PointLedgerItem, +} from "@/lib/api"; +import Avatar from "@/components/Avatar"; +import LevelBadge from "@/components/LevelBadge"; +import Modal from "@/components/Modal"; +import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/components/admin"; +import { pointReasonLabel } from "@/lib/points"; +import { formatRelative } from "@/lib/format"; +import { toast } from "@/lib/toast"; + +const PAGE_SIZE = 20; +const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致 +const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN"); + +type PointsSortKey = "id" | "points" | "total_points"; + +/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */ +function SortHeader({ + label, + active, + dir, + onClick, +}: { + label: string; + active: boolean; + dir: "asc" | "desc"; + onClick: () => void; +}) { + return ( + + ); +} + +/** + * 用户积分标签:搜索用户 → 余额/累计/等级一览;行内「调整」弹窗(加分计累计可升级, + * 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。 + */ +export default function UserPointsTab() { + const [keyword, setKeyword] = useState(""); + const [appliedQ, setAppliedQ] = useState(""); + const [page, setPage] = useState(1); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [loaded, setLoaded] = useState(false); + const [sort, setSort] = useState("id"); + const [dir, setDir] = useState<"asc" | "desc">("desc"); + + const [adjustTarget, setAdjustTarget] = useState(null); + const [ledgerTarget, setLedgerTarget] = useState(null); + + const seqRef = useRef(0); + const debounceRef = useRef | null>(null); + // 排序走 ref:load 身份保持稳定,防抖搜索与切页自动沿用当前排序 + const sortRef = useRef<{ sort: PointsSortKey; dir: "asc" | "desc" }>({ sort: "id", dir: "desc" }); + + const load = useCallback(async (opts: { page: number; q: string }) => { + const seq = ++seqRef.current; + setLoading(true); + try { + const res = await apiAdminListUsers({ + page: opts.page, + size: PAGE_SIZE, + q: opts.q || undefined, + sort: sortRef.current.sort, + order: sortRef.current.dir, + }); + if (seq !== seqRef.current) return; + setUsers(res.users); + setTotal(res.total); + setPage(opts.page); + setAppliedQ(opts.q); + setError(""); + setLoaded(true); + } catch (e) { + if (seq !== seqRef.current) return; + setError(e instanceof Error ? e.message : "获取用户列表失败"); + } finally { + if (seq === seqRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + void load({ page: 1, q: "" }); + }, [load]); + + // 搜索 400ms 防抖(对齐 UsersAdmin) + const onKeywordChange = useCallback( + (v: string) => { + setKeyword(v); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void load({ page: 1, q: v.trim() }); + }, 400); + }, + [load] + ); + + const patchUser = useCallback((id: number, patch: Partial) => { + setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u))); + }, []); + + // 切换排序:点未激活列 → 该列降序;点已激活列 → 升降切换;始终回到第 1 页 + const toggleSort = useCallback( + (key: PointsSortKey) => { + const cur = sortRef.current; + const next = cur.sort === key ? { sort: key, dir: cur.dir === "desc" ? ("asc" as const) : ("desc" as const) } : { sort: key, dir: "desc" as const }; + sortRef.current = next; + setSort(next.sort); + setDir(next.dir); + void load({ page: 1, q: appliedQ }); + }, + [load, appliedQ] + ); + + return ( +
+ + +
+ +
+ +
+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
+ 用户 + + toggleSort("points")} /> + + toggleSort("total_points")} /> + + 等级 + + 操作 +
+ + + + + {u.nickname || u.username} + + @{u.username} + + + + {fmt(u.points)} + + {fmt(u.total_points)} + + + +
+ + +
+
+
+ {loading ?

加载中…

: null} + {!loading && error ?

{error}

: null} + {!loading && !error && loaded && users.length === 0 ? ( +

没有匹配的用户

+ ) : null} +
+ + {!error && users.length > 0 ? ( +
+ void load({ page: p, q: appliedQ })} /> +
+ ) : null} + + {adjustTarget ? ( + setAdjustTarget(null)} + onAdjusted={(balance, totalPoints, level) => { + patchUser(adjustTarget.id, { points: balance, total_points: totalPoints, level }); + }} + /> + ) : null} + {ledgerTarget ? ( + setLedgerTarget(null)} /> + ) : null} +
+ ); +} + +/** 调整弹窗:方向(加/减)+ 数量 + 理由;成功后回填余额/累计/等级 */ +function AdjustModal({ + user, + onClose, + onAdjusted, +}: { + user: AdminUser; + onClose: () => void; + onAdjusted: (balance: number, totalPoints: number, level: number) => void; +}) { + const [dir, setDir] = useState<"add" | "sub">("add"); + const [amount, setAmount] = useState(10); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + const submit = async () => { + if (saving) return; + if (!Number.isInteger(amount) || amount < 1 || amount > MAX_DELTA) { + toast(`积分数量须为 1–${MAX_DELTA} 的整数`); + return; + } + const trimmed = note.trim(); + if (!trimmed) { + toast("请填写调整理由"); + return; + } + setSaving(true); + try { + const delta = dir === "add" ? amount : -amount; + const res = await apiAdminAdjustUserPoints(user.id, delta, trimmed); + onAdjusted(res.balance, res.total_points, res.level); + toast( + `已${dir === "add" ? "增加" : "扣减"} ${fmt(amount)} 积分:余额 ${fmt(res.balance)},当前 Lv.${res.level}`, + "ok" + ); + onClose(); + } catch (e) { + toast(e instanceof Error ? e.message : "积分调整失败"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > +
+
+

+ 方向 +

+
+ + +
+

+ {dir === "add" + ? "加分同时计入累计获得,可能触发升级" + : "扣分仅扣减余额,不改变累计获得与等级"} +

+
+ +
+

+ 数量(1–{MAX_DELTA}) +

+ +
+ +
+

+ 调整理由(必填,写入积分流水) +

+