徽章
This commit is contained in:
parent
31433b0a1b
commit
41c1d326ee
@ -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,
|
||||
|
||||
49
internal/model/badge_models.go
Normal file
49
internal/model/badge_models.go
Normal 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" }
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
25
internal/router/user_badge_routes.go
Normal file
25
internal/router/user_badge_routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
121
internal/router/user_badge_routes_test.go
Normal file
121
internal/router/user_badge_routes_test.go
Normal 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
|
||||
}
|
||||
205
internal/service/userbadge/current.go
Normal file
205
internal/service/userbadge/current.go
Normal 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)
|
||||
}
|
||||
137
internal/service/userbadge/current_test.go
Normal file
137
internal/service/userbadge/current_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
122
internal/service/userbadge/types.go
Normal file
122
internal/service/userbadge/types.go
Normal 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 ""
|
||||
}
|
||||
@ -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)
|
||||
|
||||
44
internal/service/voiceroomrocket/config_test.go
Normal file
44
internal/service/voiceroomrocket/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
|
||||
16
migrations/037_voice_room_rocket_preview_rocket.sql
Normal file
16
migrations/037_voice_room_rocket_preview_rocket.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user