From 41c1d326eecc3a05dce2f551e666b9c05620f495 Mon Sep 17 00:00:00 2001 From: hy001 Date: Fri, 15 May 2026 12:07:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/main.go | 3 + internal/model/badge_models.go | 49 +++++ internal/model/voice_room_rocket_models.go | 1 + internal/router/router.go | 3 + internal/router/user_badge_routes.go | 25 +++ internal/router/user_badge_routes_test.go | 121 +++++++++++ internal/service/userbadge/current.go | 205 ++++++++++++++++++ internal/service/userbadge/current_test.go | 137 ++++++++++++ internal/service/userbadge/types.go | 122 +++++++++++ internal/service/voiceroomrocket/config.go | 1 + .../service/voiceroomrocket/config_test.go | 44 ++++ internal/service/voiceroomrocket/mapper.go | 1 + internal/service/voiceroomrocket/types.go | 1 + migrations/032_voice_room_rocket.sql | 1 + .../037_voice_room_rocket_preview_rocket.sql | 16 ++ 15 files changed, 730 insertions(+) create mode 100644 internal/model/badge_models.go create mode 100644 internal/router/user_badge_routes.go create mode 100644 internal/router/user_badge_routes_test.go create mode 100644 internal/service/userbadge/current.go create mode 100644 internal/service/userbadge/current_test.go create mode 100644 internal/service/userbadge/types.go create mode 100644 internal/service/voiceroomrocket/config_test.go create mode 100644 migrations/037_voice_room_rocket_preview_rocket.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index 5e55700..76b9d71 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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, diff --git a/internal/model/badge_models.go b/internal/model/badge_models.go new file mode 100644 index 0000000..69a695f --- /dev/null +++ b/internal/model/badge_models.go @@ -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" } diff --git a/internal/model/voice_room_rocket_models.go b/internal/model/voice_room_rocket_models.go index ad6b884..4eae016 100644 --- a/internal/model/voice_room_rocket_models.go +++ b/internal/model/voice_room_rocket_models.go @@ -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"` diff --git a/internal/router/router.go b/internal/router/router.go index 4b0d80c..50a2806 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/router/user_badge_routes.go b/internal/router/user_badge_routes.go new file mode 100644 index 0000000..4861bec --- /dev/null +++ b/internal/router/user_badge_routes.go @@ -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) + }) +} diff --git a/internal/router/user_badge_routes_test.go b/internal/router/user_badge_routes_test.go new file mode 100644 index 0000000..4b48794 --- /dev/null +++ b/internal/router/user_badge_routes_test.go @@ -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 +} diff --git a/internal/service/userbadge/current.go b/internal/service/userbadge/current.go new file mode 100644 index 0000000..9bcd743 --- /dev/null +++ b/internal/service/userbadge/current.go @@ -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) +} diff --git a/internal/service/userbadge/current_test.go b/internal/service/userbadge/current_test.go new file mode 100644 index 0000000..62bae6b --- /dev/null +++ b/internal/service/userbadge/current_test.go @@ -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) + } +} diff --git a/internal/service/userbadge/types.go b/internal/service/userbadge/types.go new file mode 100644 index 0000000..9a68a92 --- /dev/null +++ b/internal/service/userbadge/types.go @@ -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 "" +} diff --git a/internal/service/voiceroomrocket/config.go b/internal/service/voiceroomrocket/config.go index d56d385..9389828 100644 --- a/internal/service/voiceroomrocket/config.go +++ b/internal/service/voiceroomrocket/config.go @@ -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) diff --git a/internal/service/voiceroomrocket/config_test.go b/internal/service/voiceroomrocket/config_test.go new file mode 100644 index 0000000..ee0da8b --- /dev/null +++ b/internal/service/voiceroomrocket/config_test.go @@ -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) + } +} diff --git a/internal/service/voiceroomrocket/mapper.go b/internal/service/voiceroomrocket/mapper.go index 38c4939..db05321 100644 --- a/internal/service/voiceroomrocket/mapper.go +++ b/internal/service/voiceroomrocket/mapper.go @@ -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, diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go index 2efdf38..2ed35dc 100644 --- a/internal/service/voiceroomrocket/types.go +++ b/internal/service/voiceroomrocket/types.go @@ -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"` diff --git a/migrations/032_voice_room_rocket.sql b/migrations/032_voice_room_rocket.sql index c063383..4b050dc 100644 --- a/migrations/032_voice_room_rocket.sql +++ b/migrations/032_voice_room_rocket.sql @@ -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, diff --git a/migrations/037_voice_room_rocket_preview_rocket.sql b/migrations/037_voice_room_rocket_preview_rocket.sql new file mode 100644 index 0000000..1177ef6 --- /dev/null +++ b/migrations/037_voice_room_rocket_preview_rocket.sql @@ -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;