feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
@@ -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