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

@@ -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
View 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")
}

View 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("色板外颜色应被拒绝")
}
}

View File

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

View File

@@ -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 == "" {

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

@@ -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)) {

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
}