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

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

View File

@@ -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
}