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/signinreward"
"chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
@ -58,6 +59,7 @@ func main() {
rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &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) 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) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.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) smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -117,6 +119,7 @@ func main() {
SignInReward: signInRewardService, SignInReward: signInRewardService,
SmashEgg: smashEggService, SmashEgg: smashEggService,
TaskCenter: taskCenterService, TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService, VIP: vipService,
Wheel: wheelService, Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService, 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"` Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"`
NeedEnergy int64 `gorm:"column:need_energy"` NeedEnergy int64 `gorm:"column:need_energy"`
ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"` 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"` RocketIconURL string `gorm:"column:rocket_icon_url;size:512"`
RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"` RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"`
ProgressBarURL string `gorm:"column:progress_bar_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/signinreward"
"chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
@ -53,6 +54,7 @@ type Services struct {
SignInReward *signinreward.SignInRewardService SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service VIP *vip.Service
Wheel *wheel.Service Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service VoiceRoomRedPacket *voiceroomredpacket.Service
@ -79,6 +81,7 @@ func NewRouter(
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP) registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, javaClient, services.Wheel) registerWheelRoutes(engine, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg) 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.NeedEnergy = item.NeedEnergy
row.ShakeThresholdPercent = threshold row.ShakeThresholdPercent = threshold
row.PreviewRocketURL = strings.TrimSpace(item.PreviewRocketURL)
row.RocketIconURL = strings.TrimSpace(item.RocketIconURL) row.RocketIconURL = strings.TrimSpace(item.RocketIconURL)
row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL) row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL)
row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL) 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, Level: row.Level,
NeedEnergy: row.NeedEnergy, NeedEnergy: row.NeedEnergy,
ShakeThresholdPercent: row.ShakeThresholdPercent, ShakeThresholdPercent: row.ShakeThresholdPercent,
PreviewRocketURL: row.PreviewRocketURL,
RocketIconURL: row.RocketIconURL, RocketIconURL: row.RocketIconURL,
RocketAnimationURL: row.RocketAnimationURL, RocketAnimationURL: row.RocketAnimationURL,
ProgressBarURL: row.ProgressBarURL, ProgressBarURL: row.ProgressBarURL,

View File

@ -315,6 +315,7 @@ type LevelConfigPayload struct {
Level int `json:"level"` Level int `json:"level"`
NeedEnergy int64 `json:"needEnergy"` NeedEnergy int64 `json:"needEnergy"`
ShakeThresholdPercent string `json:"shakeThresholdPercent"` ShakeThresholdPercent string `json:"shakeThresholdPercent"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
RocketIconURL string `json:"rocketIconUrl,omitempty"` RocketIconURL string `json:"rocketIconUrl,omitempty"`
RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"` RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"`
ProgressBarURL string `json:"progressBarUrl,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', level INT NOT NULL COMMENT 'Lv.1-Lv.6',
need_energy BIGINT NOT NULL COMMENT '本级发射所需能量', need_energy BIGINT NOT NULL COMMENT '本级发射所需能量',
shake_threshold_percent DECIMAL(8,4) NOT NULL DEFAULT 0 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_icon_url VARCHAR(512) DEFAULT NULL,
rocket_animation_url VARCHAR(512) DEFAULT NULL, rocket_animation_url VARCHAR(512) DEFAULT NULL,
progress_bar_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;