This commit is contained in:
hy001 2026-05-15 12:07:26 +08:00
parent 31433b0a1b
commit 41c1d326ee
15 changed files with 730 additions and 0 deletions

View File

@ -30,6 +30,7 @@ import (
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
@ -58,6 +59,7 @@ func main() {
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
userBadgeService := userbadge.NewService(app.Repository.DB)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -117,6 +119,7 @@ func main() {
SignInReward: signInRewardService,
SmashEgg: smashEggService,
TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService,
Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService,

View File

@ -0,0 +1,49 @@
package model
import "time"
// UserBadgeBackpack stores user-owned badge records from the Java badge system.
type UserBadgeBackpack struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id;index:idx_user_badge_backpack_user"`
BadgeID int64 `gorm:"column:badge_id;index:idx_user_badge_backpack_badge"`
ExpireType string `gorm:"column:expire_type;size:32"`
ExpireTime time.Time `gorm:"column:expire_time"`
UseProps bool `gorm:"column:is_use_props"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the user badge backpack table name.
func (UserBadgeBackpack) TableName() string { return "user_badge_backpack" }
// SysBadgeConfig stores badge metadata from the Java badge system.
type SysBadgeConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type;size:64"`
BadgeLevel int `gorm:"column:badge_level"`
Milestone int64 `gorm:"column:milestone"`
BadgeName string `gorm:"column:badge_name;size:128"`
BadgeKey string `gorm:"column:badge_key;size:128"`
Del bool `gorm:"column:is_del"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the badge config table name.
func (SysBadgeConfig) TableName() string { return "sys_badge_config" }
// SysBadgePictureConfig stores per-platform badge images.
type SysBadgePictureConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
BadgeConfigID int64 `gorm:"column:badge_config_id;index:idx_badge_picture_config_badge"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_badge_picture_config_sys"`
SelectURL string `gorm:"column:select_url;size:512"`
NotSelectURL string `gorm:"column:not_select_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the badge picture config table name.
func (SysBadgePictureConfig) TableName() string { return "sys_badge_picture_config" }

View File

@ -28,6 +28,7 @@ type VoiceRoomRocketLevelConfig struct {
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"`
NeedEnergy int64 `gorm:"column:need_energy"`
ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"`
PreviewRocketURL string `gorm:"column:preview_rocket_url;size:512"`
RocketIconURL string `gorm:"column:rocket_icon_url;size:512"`
RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"`
ProgressBarURL string `gorm:"column:progress_bar_url;size:512"`

View File

@ -24,6 +24,7 @@ import (
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket"
@ -53,6 +54,7 @@ type Services struct {
SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service
Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service
@ -79,6 +81,7 @@ func NewRouter(
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)

View File

@ -0,0 +1,25 @@
package router
import (
"chatapp3-golang/internal/service/userbadge"
"github.com/gin-gonic/gin"
)
// registerUserBadgeRoutes registers App user badge APIs.
func registerUserBadgeRoutes(engine *gin.Engine, javaClient authGateway, service *userbadge.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/user/badge")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/current", func(c *gin.Context) {
resp, err := service.Current(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -0,0 +1,121 @@
package router
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/repo"
"chatapp3-golang/internal/service/userbadge"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type userBadgeAuthStub struct{}
func (userBadgeAuthStub) AuthenticateToken(context.Context, string) (integration.UserCredential, error) {
return integration.UserCredential{UserID: 123, SysOrigin: "LIKEI"}, nil
}
func (userBadgeAuthStub) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) {
return integration.ConsoleAccount{}, nil
}
func TestUserBadgeCurrentRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newRouterUserBadgeDB(t)
now := time.Now().Add(time.Hour)
if err := db.Create(&model.UserBadgeBackpack{
ID: 1,
UserID: 123,
BadgeID: 501,
ExpireType: "TEMPORARY",
ExpireTime: now,
UseProps: true,
UpdateTime: time.Now(),
}).Error; err != nil {
t.Fatalf("create backpack: %v", err)
}
if err := db.Create(&model.SysBadgeConfig{
ID: 501,
Type: "ACTIVITY",
BadgeName: "Activity Badge",
BadgeKey: "ACTIVITY_BADGE",
}).Error; err != nil {
t.Fatalf("create badge config: %v", err)
}
if err := db.Create(&model.SysBadgePictureConfig{
ID: 601,
BadgeConfigID: 501,
SysOrigin: "LIKEI",
SelectURL: "https://cdn.example.com/activity.png",
}).Error; err != nil {
t.Fatalf("create badge picture: %v", err)
}
service := userbadge.NewService(db)
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/current", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("data missing: %#v", payload)
}
shortBadges, ok := data["shortBadges"].([]any)
if !ok || len(shortBadges) != 1 {
t.Fatalf("shortBadges = %#v", data["shortBadges"])
}
item, ok := shortBadges[0].(map[string]any)
if !ok || item["type"] != "SHORT" || item["badgeId"] != "501" {
t.Fatalf("short badge item = %#v", shortBadges[0])
}
}
func TestUserBadgeCurrentRouteRequiresAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
service := userbadge.NewService(newRouterUserBadgeDB(t))
engine := NewRouter(config.Config{}, &repo.Repository{}, userBadgeAuthStub{}, Services{UserBadge: service})
req := httptest.NewRequest(http.MethodGet, "/app/user/badge/current", nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d body = %s, want 401", rec.Code, rec.Body.String())
}
}
func newRouterUserBadgeDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.UserVipState{},
&model.UserBadgeBackpack{},
&model.SysBadgeConfig{},
&model.SysBadgePictureConfig{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
return db
}

View File

@ -0,0 +1,205 @@
package userbadge
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
type badgePictureRow struct {
BadgeID int64 `gorm:"column:badge_id"`
SourceType sql.NullString `gorm:"column:source_type"`
BadgeLevel sql.NullInt64 `gorm:"column:badge_level"`
Milestone sql.NullInt64 `gorm:"column:milestone"`
BadgeName sql.NullString `gorm:"column:badge_name"`
BadgeKey sql.NullString `gorm:"column:badge_key"`
SelectURL sql.NullString `gorm:"column:select_url"`
NotSelectURL sql.NullString `gorm:"column:not_select_url"`
AnimationURL sql.NullString `gorm:"column:animation_url"`
ExpireTime time.Time `gorm:"column:expire_time"`
}
// Current returns the current user's badge list split by long and short display type.
func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeListResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
}
sysOrigin, err := normalizeUser(user)
if err != nil {
return nil, err
}
now := s.now()
longBadges, vipBadgeID, err := s.loadLongBadges(ctx, sysOrigin, user.UserID, now)
if err != nil {
return nil, err
}
shortBadges, err := s.loadShortBadges(ctx, sysOrigin, user.UserID, vipBadgeID, now)
if err != nil {
return nil, err
}
badges := make([]CurrentBadgeItem, 0, len(longBadges)+len(shortBadges))
badges = append(badges, longBadges...)
badges = append(badges, shortBadges...)
return &CurrentBadgeListResponse{
UserID: user.UserID,
SysOrigin: sysOrigin,
Badges: badges,
LongBadges: longBadges,
ShortBadges: shortBadges,
}, nil
}
func (s *Service) loadLongBadges(ctx context.Context, sysOrigin string, userID int64, now time.Time) ([]CurrentBadgeItem, int64, error) {
var state model.UserVipState
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).
First(&state).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return []CurrentBadgeItem{}, 0, nil
}
if err != nil {
return nil, 0, err
}
if !isActiveVIPState(state, now) || state.BadgeResourceID <= 0 {
return []CurrentBadgeItem{}, 0, nil
}
row, err := s.loadBadgePicture(ctx, sysOrigin, state.BadgeResourceID)
if err != nil {
return nil, 0, err
}
item := CurrentBadgeItem{
ID: formatID(state.BadgeResourceID),
BadgeID: formatID(state.BadgeResourceID),
Type: badgeDisplayTypeLong,
SourceType: badgeSourceTypeVIP,
BadgeLevel: nullInt(row.BadgeLevel),
BadgeKey: nullString(row.BadgeKey),
Level: state.Level,
LevelCode: state.LevelCode,
DisplayName: state.DisplayName,
Use: true,
ExpireTime: formatMillis(state.ExpireAt),
ExpireAt: formatTime(state.ExpireAt),
}
item.BadgeName = firstNonEmpty(nullString(row.BadgeName), state.BadgeName, state.DisplayName)
item.Name = item.BadgeName
item.SelectURL = firstNonEmpty(nullString(row.SelectURL), state.BadgeURL)
item.NotSelectURL = nullString(row.NotSelectURL)
item.AnimationURL = nullString(row.AnimationURL)
item.URL = firstNonEmpty(item.SelectURL, state.BadgeURL)
if row.Milestone.Valid && row.Milestone.Int64 > 0 {
item.Milestone = formatID(row.Milestone.Int64)
}
if item.Name == "" && item.URL == "" {
return []CurrentBadgeItem{}, state.BadgeResourceID, nil
}
return []CurrentBadgeItem{item}, state.BadgeResourceID, nil
}
func (s *Service) loadShortBadges(ctx context.Context, sysOrigin string, userID int64, excludeBadgeID int64, now time.Time) ([]CurrentBadgeItem, error) {
rows := make([]badgePictureRow, 0)
query := s.db.WithContext(ctx).
Table("user_badge_backpack AS backpack").
Select(`
backpack.badge_id AS badge_id,
backpack.expire_time AS expire_time,
config.type AS source_type,
config.badge_level AS badge_level,
config.milestone AS milestone,
config.badge_name AS badge_name,
config.badge_key AS badge_key,
picture.select_url AS select_url,
picture.not_select_url AS not_select_url,
picture.animation_url AS animation_url
`).
Joins("JOIN sys_badge_config AS config ON config.id = backpack.badge_id").
Joins("JOIN sys_badge_picture_config AS picture ON picture.badge_config_id = config.id AND picture.sys_origin = ?", sysOrigin).
Where("backpack.user_id = ?", userID).
Where("backpack.is_use_props = ?", true).
Where("config.is_del = ?", false).
Where("config.type IN ?", []string{badgeSourceTypeActivity, badgeSourceTypeAchievement}).
Where("(backpack.expire_type = ? OR backpack.expire_time > ?)", expireTypePermanent, now).
Order("backpack.update_time DESC, backpack.id DESC")
if excludeBadgeID > 0 {
query = query.Where("backpack.badge_id <> ?", excludeBadgeID)
}
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]CurrentBadgeItem, 0, len(rows))
for _, row := range rows {
item := CurrentBadgeItem{
ID: formatID(row.BadgeID),
BadgeID: formatID(row.BadgeID),
Type: badgeDisplayTypeShort,
SourceType: nullString(row.SourceType),
Name: nullString(row.BadgeName),
BadgeName: nullString(row.BadgeName),
BadgeKey: nullString(row.BadgeKey),
BadgeLevel: nullInt(row.BadgeLevel),
URL: nullString(row.SelectURL),
SelectURL: nullString(row.SelectURL),
NotSelectURL: nullString(row.NotSelectURL),
AnimationURL: nullString(row.AnimationURL),
Use: true,
ExpireTime: formatMillis(row.ExpireTime),
ExpireAt: formatTime(row.ExpireTime),
}
if row.Milestone.Valid && row.Milestone.Int64 > 0 {
item.Milestone = formatID(row.Milestone.Int64)
}
items = append(items, item)
}
return items, nil
}
func (s *Service) loadBadgePicture(ctx context.Context, sysOrigin string, badgeID int64) (badgePictureRow, error) {
var row badgePictureRow
err := s.db.WithContext(ctx).
Table("sys_badge_config AS config").
Select(`
config.id AS badge_id,
config.type AS source_type,
config.badge_level AS badge_level,
config.milestone AS milestone,
config.badge_name AS badge_name,
config.badge_key AS badge_key,
picture.select_url AS select_url,
picture.not_select_url AS not_select_url,
picture.animation_url AS animation_url
`).
Joins("LEFT JOIN sys_badge_picture_config AS picture ON picture.badge_config_id = config.id AND picture.sys_origin = ?", sysOrigin).
Where("config.id = ? AND config.is_del = ?", badgeID, false).
Scan(&row).Error
return row, err
}
func isActiveVIPState(state model.UserVipState, now time.Time) bool {
return strings.EqualFold(strings.TrimSpace(state.Status), statusActive) && state.Level > 0 && state.ExpireAt.After(now)
}
func nullString(value sql.NullString) string {
if !value.Valid {
return ""
}
return strings.TrimSpace(value.String)
}
func nullInt(value sql.NullInt64) int {
if !value.Valid {
return 0
}
return int(value.Int64)
}

View File

@ -0,0 +1,137 @@
package userbadge
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestCurrentBadgesSplitLongAndShort(t *testing.T) {
db := newUserBadgeTestDB(t)
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
userID := int64(42)
if err := db.Create(&model.UserVipState{
ID: 1,
SysOrigin: "LIKEI",
UserID: userID,
Level: 2,
LevelCode: "VIP2",
DisplayName: "VIP 2",
Status: statusActive,
StartAt: now.Add(-time.Hour),
ExpireAt: now.Add(24 * time.Hour),
BadgeResourceID: 101,
}).Error; err != nil {
t.Fatalf("create vip state: %v", err)
}
seedBadgeConfig(t, db, 101, "ACTIVITY", "VIP Badge", "VIP_BADGE", 5)
seedBadgeConfig(t, db, 102, badgeSourceTypeActivity, "Activity Badge", "ACTIVITY_BADGE", 3)
seedBadgeConfig(t, db, 103, badgeSourceTypeAchievement, "Achievement Badge", "ACHIEVEMENT_BADGE", 7)
seedBadgeConfig(t, db, 104, "ADMINISTRATOR", "Admin Badge", "ADMIN_BADGE", 0)
seedBadgeConfig(t, db, 105, badgeSourceTypeActivity, "Expired Badge", "EXPIRED_BADGE", 0)
seedBadgeConfig(t, db, 106, badgeSourceTypeActivity, "Unused Badge", "UNUSED_BADGE", 0)
for _, badgeID := range []int64{101, 102, 103, 104, 105, 106} {
if err := db.Create(&model.SysBadgePictureConfig{
ID: badgeID + 1000,
BadgeConfigID: badgeID,
SysOrigin: "LIKEI",
SelectURL: "https://cdn.example.com/" + formatID(badgeID) + ".png",
NotSelectURL: "https://cdn.example.com/" + formatID(badgeID) + "-off.png",
}).Error; err != nil {
t.Fatalf("create picture %d: %v", badgeID, err)
}
}
backpacks := []model.UserBadgeBackpack{
{ID: 2001, UserID: userID, BadgeID: 101, ExpireType: "TEMPORARY", ExpireTime: now.Add(24 * time.Hour), UseProps: true, UpdateTime: now.Add(5 * time.Minute)},
{ID: 2002, UserID: userID, BadgeID: 102, ExpireType: "TEMPORARY", ExpireTime: now.Add(2 * time.Hour), UseProps: true, UpdateTime: now.Add(4 * time.Minute)},
{ID: 2003, UserID: userID, BadgeID: 103, ExpireType: expireTypePermanent, ExpireTime: now.Add(-24 * time.Hour), UseProps: true, UpdateTime: now.Add(3 * time.Minute)},
{ID: 2004, UserID: userID, BadgeID: 104, ExpireType: "TEMPORARY", ExpireTime: now.Add(2 * time.Hour), UseProps: true, UpdateTime: now.Add(2 * time.Minute)},
{ID: 2005, UserID: userID, BadgeID: 105, ExpireType: "TEMPORARY", ExpireTime: now.Add(-time.Minute), UseProps: true, UpdateTime: now.Add(time.Minute)},
{ID: 2006, UserID: userID, BadgeID: 106, ExpireType: "TEMPORARY", ExpireTime: now.Add(2 * time.Hour), UseProps: false, UpdateTime: now},
}
if err := db.Create(&backpacks).Error; err != nil {
t.Fatalf("create backpacks: %v", err)
}
service := NewService(db)
service.now = func() time.Time { return now }
resp, err := service.Current(context.Background(), common.AuthUser{UserID: userID, SysOrigin: "likei"})
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if resp.SysOrigin != "LIKEI" {
t.Fatalf("SysOrigin = %q, want LIKEI", resp.SysOrigin)
}
if len(resp.LongBadges) != 1 {
t.Fatalf("long badges len = %d, want 1: %#v", len(resp.LongBadges), resp.LongBadges)
}
if len(resp.ShortBadges) != 2 {
t.Fatalf("short badges len = %d, want 2: %#v", len(resp.ShortBadges), resp.ShortBadges)
}
if len(resp.Badges) != 3 {
t.Fatalf("badges len = %d, want 3", len(resp.Badges))
}
long := resp.LongBadges[0]
if long.Type != badgeDisplayTypeLong || long.SourceType != badgeSourceTypeVIP || long.BadgeID != "101" || long.LevelCode != "VIP2" {
t.Fatalf("long badge = %#v", long)
}
if long.Name != "VIP Badge" || long.URL != "https://cdn.example.com/101.png" {
t.Fatalf("long badge name/url = %q/%q", long.Name, long.URL)
}
if resp.ShortBadges[0].BadgeID != "102" || resp.ShortBadges[0].SourceType != badgeSourceTypeActivity {
t.Fatalf("first short badge = %#v", resp.ShortBadges[0])
}
if resp.ShortBadges[1].BadgeID != "103" || resp.ShortBadges[1].SourceType != badgeSourceTypeAchievement {
t.Fatalf("second short badge = %#v", resp.ShortBadges[1])
}
}
func TestCurrentBadgesRejectsInvalidUser(t *testing.T) {
service := NewService(newUserBadgeTestDB(t))
_, err := service.Current(context.Background(), common.AuthUser{SysOrigin: "LIKEI"})
if err == nil {
t.Fatalf("Current() error = nil, want invalid user")
}
appErr, ok := err.(*AppError)
if !ok || appErr.Code != "invalid_user" {
t.Fatalf("error = %#v, want invalid_user", err)
}
}
func newUserBadgeTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.UserVipState{},
&model.UserBadgeBackpack{},
&model.SysBadgeConfig{},
&model.SysBadgePictureConfig{},
); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
return db
}
func seedBadgeConfig(t *testing.T, db *gorm.DB, id int64, sourceType string, name string, key string, level int) {
t.Helper()
if err := db.Create(&model.SysBadgeConfig{
ID: id,
Type: sourceType,
BadgeName: name,
BadgeKey: key,
BadgeLevel: level,
Milestone: id * 10,
Del: false,
}).Error; err != nil {
t.Fatalf("create badge config %d: %v", id, err)
}
}

View File

@ -0,0 +1,122 @@
package userbadge
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"gorm.io/gorm"
)
const (
badgeDisplayTypeLong = "LONG"
badgeDisplayTypeShort = "SHORT"
badgeSourceTypeVIP = "VIP"
badgeSourceTypeActivity = "ACTIVITY"
badgeSourceTypeAchievement = "ACHIEVEMENT"
expireTypePermanent = "PERMANENT"
statusActive = "ACTIVE"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service provides current-user badge aggregation for App clients.
type Service struct {
db dbHandle
now func() time.Time
}
// NewService creates the user badge aggregation service.
func NewService(db dbHandle) *Service {
return &Service{
db: db,
now: time.Now,
}
}
// CurrentBadgeListResponse is the App-facing current badge payload.
type CurrentBadgeListResponse struct {
UserID int64 `json:"userId"`
SysOrigin string `json:"sysOrigin"`
Badges []CurrentBadgeItem `json:"badges"`
LongBadges []CurrentBadgeItem `json:"longBadges"`
ShortBadges []CurrentBadgeItem `json:"shortBadges"`
}
// CurrentBadgeItem is one current badge. Type is LONG for VIP badges and SHORT
// for activity or achievement badges.
type CurrentBadgeItem struct {
ID string `json:"id"`
BadgeID string `json:"badgeId"`
Type string `json:"type"`
SourceType string `json:"sourceType"`
Name string `json:"name"`
BadgeName string `json:"badgeName"`
BadgeKey string `json:"badgeKey,omitempty"`
BadgeLevel int `json:"badgeLevel,omitempty"`
Milestone string `json:"milestone,omitempty"`
URL string `json:"url,omitempty"`
SelectURL string `json:"selectUrl,omitempty"`
NotSelectURL string `json:"notSelectUrl,omitempty"`
AnimationURL string `json:"animationUrl,omitempty"`
Level int `json:"level,omitempty"`
LevelCode string `json:"levelCode,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Use bool `json:"use"`
ExpireTime int64 `json:"expireTime,omitempty"`
ExpireAt string `json:"expireAt,omitempty"`
}
func normalizeUser(user AuthUser) (string, error) {
if user.UserID <= 0 {
return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required")
}
sysOrigin := strings.ToUpper(strings.TrimSpace(user.SysOrigin))
if sysOrigin == "" {
return "", NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
return sysOrigin, nil
}
func formatID(id int64) string {
if id <= 0 {
return ""
}
return strconv.FormatInt(id, 10)
}
func formatMillis(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixMilli()
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@ -163,6 +163,7 @@ func (s *Service) SaveLevelConfigs(ctx context.Context, req SaveLevelConfigsRequ
}
row.NeedEnergy = item.NeedEnergy
row.ShakeThresholdPercent = threshold
row.PreviewRocketURL = strings.TrimSpace(item.PreviewRocketURL)
row.RocketIconURL = strings.TrimSpace(item.RocketIconURL)
row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL)
row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL)

View File

@ -0,0 +1,44 @@
package voiceroomrocket
import (
"context"
"testing"
)
func TestSaveLevelConfigsPersistsPreviewRocketURL(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
saved, err := service.SaveLevelConfigs(ctx, SaveLevelConfigsRequest{
SysOrigin: "LIKEI",
Levels: []LevelConfigPayload{
{
Level: 1,
NeedEnergy: 100,
ShakeThresholdPercent: "95.0000",
PreviewRocketURL: "https://cdn.example.com/preview-rocket.png",
RocketIconURL: "https://cdn.example.com/rocket-icon.png",
RocketAnimationURL: "https://cdn.example.com/rocket-launch.svga",
Enabled: true,
Sort: 1,
},
},
})
if err != nil {
t.Fatalf("SaveLevelConfigs error = %v", err)
}
if len(saved) != 1 {
t.Fatalf("saved level count = %d, want 1", len(saved))
}
if saved[0].PreviewRocketURL != "https://cdn.example.com/preview-rocket.png" {
t.Fatalf("previewRocketUrl = %q", saved[0].PreviewRocketURL)
}
listed, err := service.ListLevelConfigs(ctx, "LIKEI")
if err != nil {
t.Fatalf("ListLevelConfigs error = %v", err)
}
if len(listed) != 1 || listed[0].PreviewRocketURL != saved[0].PreviewRocketURL {
t.Fatalf("listed levels = %+v", listed)
}
}

View File

@ -140,6 +140,7 @@ func levelPayload(row model.VoiceRoomRocketLevelConfig) LevelConfigPayload {
Level: row.Level,
NeedEnergy: row.NeedEnergy,
ShakeThresholdPercent: row.ShakeThresholdPercent,
PreviewRocketURL: row.PreviewRocketURL,
RocketIconURL: row.RocketIconURL,
RocketAnimationURL: row.RocketAnimationURL,
ProgressBarURL: row.ProgressBarURL,

View File

@ -315,6 +315,7 @@ type LevelConfigPayload struct {
Level int `json:"level"`
NeedEnergy int64 `json:"needEnergy"`
ShakeThresholdPercent string `json:"shakeThresholdPercent"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
RocketIconURL string `json:"rocketIconUrl,omitempty"`
RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"`
ProgressBarURL string `json:"progressBarUrl,omitempty"`

View File

@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS voice_room_rocket_level_config (
level INT NOT NULL COMMENT 'Lv.1-Lv.6',
need_energy BIGINT NOT NULL COMMENT '本级发射所需能量',
shake_threshold_percent DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '抖动阈值百分比',
preview_rocket_url VARCHAR(512) DEFAULT NULL COMMENT '预览火箭资源URL',
rocket_icon_url VARCHAR(512) DEFAULT NULL,
rocket_animation_url VARCHAR(512) DEFAULT NULL,
progress_bar_url VARCHAR(512) DEFAULT NULL,

View File

@ -0,0 +1,16 @@
SET @current_schema := DATABASE();
SET @add_voice_room_rocket_preview_url_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'voice_room_rocket_level_config'
AND column_name = 'preview_rocket_url'
),
'ALTER TABLE `voice_room_rocket_level_config` ADD COLUMN `preview_rocket_url` varchar(512) DEFAULT NULL COMMENT ''预览火箭资源URL'' AFTER `shake_threshold_percent`',
'SELECT 1'
);
PREPARE add_voice_room_rocket_preview_url_stmt FROM @add_voice_room_rocket_preview_url_sql;
EXECUTE add_voice_room_rocket_preview_url_stmt;
DEALLOCATE PREPARE add_voice_room_rocket_preview_url_stmt;