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