diff --git a/cmd/api/main.go b/cmd/api/main.go index 5e55700..b3c1b4e 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" + "chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -30,6 +31,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" @@ -47,6 +49,7 @@ func main() { } appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis) + errorLogService := errorlog.NewService(app.Repository.DB) inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) @@ -58,6 +61,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) @@ -100,6 +104,7 @@ func main() { ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ AppPopup: appPopupService, + ErrorLog: errorLogService, Invite: inviteService, Baishun: baishunService, BinanceRecharge: binanceRechargeService, @@ -117,6 +122,7 @@ func main() { SignInReward: signInRewardService, SmashEgg: smashEggService, TaskCenter: taskCenterService, + UserBadge: userBadgeService, VIP: vipService, Wheel: wheelService, VoiceRoomRedPacket: voiceRoomRedPacketService, diff --git a/internal/model/agora_error_log_models.go b/internal/model/agora_error_log_models.go new file mode 100644 index 0000000..fe1b0e8 --- /dev/null +++ b/internal/model/agora_error_log_models.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// AgoraErrorLog stores client-reported Agora room connection errors. +type AgoraErrorLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键ID + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统来源 + UserID int64 `gorm:"column:user_id"` // 上报用户ID + RoomID string `gorm:"column:room_id;size:64"` // 房间ID + AgoraUID string `gorm:"column:agora_uid;size:64"` // Agora UID + ErrorCodeType string `gorm:"column:error_code_type;size:128"` // Agora ErrorCodeType + ConnectionChangedReasonType string `gorm:"column:connection_changed_reason_type;size:128"` // Agora ConnectionChangedReasonType + BannedByServer bool `gorm:"column:banned_by_server"` // 是否 BannedByServer/kicking-rule + IsTimeout bool `gorm:"column:is_timeout"` // 是否超时 + TokenRequestSuccess *bool `gorm:"column:token_request_success"` // token 请求是否成功 + NetworkType string `gorm:"column:network_type;size:64"` // 网络类型 + ReqClient string `gorm:"column:req_client;size:32"` // 请求客户端 + ReqAppIntel string `gorm:"column:req_app_intel;size:255"` // App 版本信息请求头 + ClientIP string `gorm:"column:client_ip;size:64"` // 客户端IP + UserAgent string `gorm:"column:user_agent;size:255"` // User-Agent + RawPayload string `gorm:"column:raw_payload;type:mediumtext"` // 原始上报内容 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName returns the Agora error log table name. +func (AgoraErrorLog) TableName() string { return "agora_error_log" } 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..d40f3cb 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"` @@ -51,6 +52,7 @@ type VoiceRoomRocketRewardConfig struct { RewardCover string `gorm:"column:reward_cover;size:512"` RewardAmount int64 `gorm:"column:reward_amount"` ExpireDays *int `gorm:"column:expire_days"` + RewardCopies int `gorm:"column:reward_copies;default:1"` Weight int `gorm:"column:weight"` Sort int `gorm:"column:sort"` Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"` diff --git a/internal/router/agora_error_log_routes.go b/internal/router/agora_error_log_routes.go new file mode 100644 index 0000000..88920b4 --- /dev/null +++ b/internal/router/agora_error_log_routes.go @@ -0,0 +1,81 @@ +package router + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/errorlog" + + "github.com/gin-gonic/gin" +) + +// registerAgoraErrorLogRoutes registers client report and admin page APIs for Agora errors. +func registerAgoraErrorLogRoutes(engine *gin.Engine, javaClient authGateway, service *errorlog.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/agora") + appGroup.Use(authMiddleware(javaClient)) + appGroup.POST("/error-log", func(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + if err != nil { + writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + var req errorlog.AgoraErrorReportRequest + if len(strings.TrimSpace(string(raw))) > 0 { + if err := json.Unmarshal(raw, &req); err != nil { + writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + } + resp, err := service.ReportAgoraError( + c.Request.Context(), + mustAuthUser(c), + req, + errorlog.AgoraErrorReportContext{ + ReqClient: c.GetHeader("Req-Client"), + ReqAppIntel: c.GetHeader("Req-App-Intel"), + ClientIP: resolveClientIP(c), + UserAgent: c.GetHeader("User-Agent"), + RawPayload: string(raw), + }, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/app-system/error-logs/agora") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + bannedOnly, _ := strconv.ParseBool(strings.TrimSpace(c.DefaultQuery("bannedOnly", "false"))) + resp, err := service.PageAgoraErrorLogs(c.Request.Context(), errorlog.AgoraErrorLogQuery{ + SysOrigin: c.Query("sysOrigin"), + UserID: parseInt64(c.Query("userId")), + RoomID: c.Query("roomId"), + AgoraUID: c.Query("agoraUid"), + ErrorCodeType: c.Query("errorCodeType"), + ReasonType: c.Query("reasonType"), + BannedOnly: bannedOnly, + NetworkType: c.Query("networkType"), + StartTime: c.Query("startTime"), + EndTime: c.Query("endTime"), + Cursor: cursor, + Limit: limit, + }) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 4b0d80c..0c6c0c7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,6 +9,7 @@ import ( "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" + "chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -24,6 +25,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" @@ -36,6 +38,7 @@ import ( // Services 聚合所有业务模块服务,供路由层统一装配。 type Services struct { AppPopup *apppopup.Service + ErrorLog *errorlog.Service Invite *invite.InviteService Baishun *baishun.BaishunService BinanceRecharge *binancerecharge.Service @@ -53,6 +56,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 @@ -72,6 +76,7 @@ func NewRouter( registerHealthRoutes(engine, cfg, repository) registerAppPopupRoutes(engine, javaClient, services.AppPopup) + registerAgoraErrorLogRoutes(engine, javaClient, services.ErrorLog) registerInviteRoutes(engine, javaClient, services.Invite) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) @@ -79,6 +84,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..4a541ba --- /dev/null +++ b/internal/router/user_badge_routes.go @@ -0,0 +1,37 @@ +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) + }) + appGroup.GET("/other", func(c *gin.Context) { + targetUserID := userbadge.ParseUserID(c.Query("userId")) + if targetUserID <= 0 { + targetUserID = userbadge.ParseUserID(c.Query("targetUserId")) + } + resp, err := service.Other(c.Request.Context(), mustAuthUser(c), targetUserID) + 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..364462f --- /dev/null +++ b/internal/router/user_badge_routes_test.go @@ -0,0 +1,199 @@ +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 TestUserBadgeOtherRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newRouterUserBadgeDB(t) + targetUserID := int64(456) + if err := db.Create(&model.UserBadgeBackpack{ + ID: 2, + UserID: targetUserID, + BadgeID: 502, + ExpireType: "TEMPORARY", + ExpireTime: time.Now().Add(time.Hour), + UseProps: true, + UpdateTime: time.Now(), + }).Error; err != nil { + t.Fatalf("create backpack: %v", err) + } + if err := db.Create(&model.SysBadgeConfig{ + ID: 502, + Type: "ACHIEVEMENT", + BadgeName: "Achievement Badge", + BadgeKey: "ACHIEVEMENT_BADGE", + }).Error; err != nil { + t.Fatalf("create badge config: %v", err) + } + if err := db.Create(&model.SysBadgePictureConfig{ + ID: 602, + BadgeConfigID: 502, + SysOrigin: "LIKEI", + SelectURL: "https://cdn.example.com/achievement.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/other?userId=456", 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) + } + if data["userId"] != float64(targetUserID) { + t.Fatalf("userId = %#v, want target user", data["userId"]) + } + 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["sourceType"] != "ACHIEVEMENT" || item["badgeId"] != "502" { + t.Fatalf("short badge item = %#v", shortBadges[0]) + } +} + +func TestUserBadgeOtherRouteRequiresTargetUser(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/other", nil) + req.Header.Set("Authorization", "Bearer token") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400", 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/errorlog/agora.go b/internal/service/errorlog/agora.go new file mode 100644 index 0000000..914421c --- /dev/null +++ b/internal/service/errorlog/agora.go @@ -0,0 +1,173 @@ +package errorlog + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// ReportAgoraError persists one client-reported Agora error log. +func (s *Service) ReportAgoraError(ctx context.Context, user AuthUser, req AgoraErrorReportRequest, meta AgoraErrorReportContext) (*AgoraErrorReportResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable") + } + roomID := req.RoomID.String() + if roomID == "" { + return nil, NewAppError(http.StatusBadRequest, "room_id_required", "roomId is required") + } + reasonType := firstNonBlank(req.ConnectionChangedReasonType, req.ReasonType, req.Reason) + bannedByServer := isBannedByServer(reasonType) + + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + row := model.AgoraErrorLog{ + ID: id, + SysOrigin: normalizeSysOrigin(user.SysOrigin), + UserID: user.UserID, + RoomID: trimMax(roomID, 64), + AgoraUID: trimMax(req.AgoraUID.String(), 64), + ErrorCodeType: trimMax(req.ErrorCodeType, 128), + ConnectionChangedReasonType: trimMax(reasonType, 128), + BannedByServer: bannedByServer, + IsTimeout: boolValue(req.Timeout, req.IsTimeout), + TokenRequestSuccess: boolPtrValue(req.TokenRequestSuccess, req.TokenSuccess), + NetworkType: trimMax(req.NetworkType, 64), + ReqClient: trimMax(meta.ReqClient, 32), + ReqAppIntel: trimMax(meta.ReqAppIntel, 255), + ClientIP: trimMax(meta.ClientIP, 64), + UserAgent: trimMax(meta.UserAgent, 255), + RawPayload: strings.TrimSpace(meta.RawPayload), + CreateTime: now, + UpdateTime: now, + } + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + return nil, err + } + return &AgoraErrorReportResponse{ + ID: strconv.FormatInt(id, 10), + BannedByServer: bannedByServer, + }, nil +} + +// PageAgoraErrorLogs returns admin Agora error logs. +func (s *Service) PageAgoraErrorLogs(ctx context.Context, query AgoraErrorLogQuery) (*AgoraErrorLogPageResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable") + } + cursor, limit := normalizePage(query.Cursor, query.Limit) + startTime, endTime, err := parseAdminTimeRange(query.StartTime, query.EndTime) + if err != nil { + return nil, err + } + filter := query + filter.Cursor = cursor + filter.Limit = limit + filter.StartAt = startTime + filter.EndAt = endTime + + base := s.agoraErrorLogQuery(ctx, filter) + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.AgoraErrorLog + if err := s.agoraErrorLogQuery(ctx, filter). + Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + return &AgoraErrorLogPageResponse{ + Records: agoraErrorLogItems(rows), + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +// AgoraErrorLogQuery is the admin filter for Agora error logs. +type AgoraErrorLogQuery struct { + SysOrigin string + UserID int64 + RoomID string + AgoraUID string + ErrorCodeType string + ReasonType string + BannedOnly bool + NetworkType string + StartTime string + EndTime string + Cursor int + Limit int + StartAt time.Time + EndAt time.Time +} + +func (s *Service) agoraErrorLogQuery(ctx context.Context, filter AgoraErrorLogQuery) *gorm.DB { + query := s.db.WithContext(ctx).Model(&model.AgoraErrorLog{}). + Where("create_time >= ? AND create_time <= ?", filter.StartAt, filter.EndAt) + if sysOrigin := normalizeSysOrigin(filter.SysOrigin); sysOrigin != "" { + query = query.Where("sys_origin = ?", sysOrigin) + } + if filter.UserID > 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if roomID := strings.TrimSpace(filter.RoomID); roomID != "" { + query = query.Where("room_id = ?", roomID) + } + if agoraUID := strings.TrimSpace(filter.AgoraUID); agoraUID != "" { + query = query.Where("agora_uid = ?", agoraUID) + } + if codeType := strings.TrimSpace(filter.ErrorCodeType); codeType != "" { + query = query.Where("error_code_type LIKE ?", "%"+codeType+"%") + } + if reasonType := strings.TrimSpace(filter.ReasonType); reasonType != "" { + query = query.Where("connection_changed_reason_type LIKE ?", "%"+reasonType+"%") + } + if filter.BannedOnly { + query = query.Where("banned_by_server = ?", true) + } + if networkType := strings.TrimSpace(filter.NetworkType); networkType != "" { + query = query.Where("network_type = ?", networkType) + } + return query +} + +func agoraErrorLogItems(rows []model.AgoraErrorLog) []AgoraErrorLogItem { + items := make([]AgoraErrorLogItem, 0, len(rows)) + for _, row := range rows { + items = append(items, AgoraErrorLogItem{ + ID: strconv.FormatInt(row.ID, 10), + SysOrigin: row.SysOrigin, + UserID: strconv.FormatInt(row.UserID, 10), + RoomID: row.RoomID, + AgoraUID: row.AgoraUID, + ErrorCodeType: row.ErrorCodeType, + ConnectionChangedReasonType: row.ConnectionChangedReasonType, + BannedByServer: row.BannedByServer, + IsTimeout: row.IsTimeout, + TokenRequestSuccess: row.TokenRequestSuccess, + NetworkType: row.NetworkType, + ReqClient: row.ReqClient, + ReqAppIntel: row.ReqAppIntel, + ClientIP: row.ClientIP, + UserAgent: row.UserAgent, + RawPayload: row.RawPayload, + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + }) + } + return items +} diff --git a/internal/service/errorlog/agora_test.go b/internal/service/errorlog/agora_test.go new file mode 100644 index 0000000..eeeaa66 --- /dev/null +++ b/internal/service/errorlog/agora_test.go @@ -0,0 +1,84 @@ +package errorlog + +import ( + "context" + "testing" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestReportAgoraErrorDetectsBannedByServer(t *testing.T) { + service := newTestService(t) + tokenOK := false + timeout := true + + resp, err := service.ReportAgoraError(context.Background(), common.AuthUser{ + UserID: 1234567, + SysOrigin: "LIKEI", + }, AgoraErrorReportRequest{ + RoomID: StringValue("9001"), + AgoraUID: StringValue("8888"), + ErrorCodeType: "ERR_LEAVE_CHANNEL_REJECTED", + ConnectionChangedReasonType: "BannedByServer(3)", + Timeout: &timeout, + TokenRequestSuccess: &tokenOK, + NetworkType: "wifi", + }, AgoraErrorReportContext{ + ReqClient: "Android", + ReqAppIntel: "version=1.0.0;build=100", + ClientIP: "127.0.0.1", + RawPayload: `{"roomId":"9001","connectionChangedReasonType":"BannedByServer(3)"}`, + }) + if err != nil { + t.Fatalf("ReportAgoraError returned error: %v", err) + } + if !resp.BannedByServer { + t.Fatalf("BannedByServer = false, want true") + } + + page, err := service.PageAgoraErrorLogs(context.Background(), AgoraErrorLogQuery{ + SysOrigin: "LIKEI", + BannedOnly: true, + Cursor: 1, + Limit: 20, + }) + if err != nil { + t.Fatalf("PageAgoraErrorLogs returned error: %v", err) + } + if page.Total != 1 || len(page.Records) != 1 { + t.Fatalf("page total=%d records=%d, want 1", page.Total, len(page.Records)) + } + record := page.Records[0] + if record.RoomID != "9001" || record.AgoraUID != "8888" || !record.IsTimeout { + t.Fatalf("unexpected record: %+v", record) + } + if record.TokenRequestSuccess == nil || *record.TokenRequestSuccess { + t.Fatalf("TokenRequestSuccess = %v, want false", record.TokenRequestSuccess) + } +} + +func TestStringValueAcceptsNumericJSON(t *testing.T) { + var value StringValue + if err := value.UnmarshalJSON([]byte(`1838887943867809794`)); err != nil { + t.Fatalf("UnmarshalJSON returned error: %v", err) + } + if value.String() != "1838887943867809794" { + t.Fatalf("value = %q, want numeric string", value.String()) + } +} + +func newTestService(t *testing.T) *Service { + 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.AgoraErrorLog{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewService(db) +} diff --git a/internal/service/errorlog/types.go b/internal/service/errorlog/types.go new file mode 100644 index 0000000..0426fe5 --- /dev/null +++ b/internal/service/errorlog/types.go @@ -0,0 +1,263 @@ +package errorlog + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + + "gorm.io/gorm" +) + +const adminTimeLayout = "2006-01-02 15:04:05" + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type dbHandle interface { + WithContext(ctx context.Context) *gorm.DB +} + +// Service handles app error logs. +type Service struct { + db dbHandle +} + +// NewService creates an app error log service. +func NewService(db dbHandle) *Service { + return &Service{db: db} +} + +// StringValue accepts JSON string or number and stores it as a string. +type StringValue string + +func (v StringValue) String() string { + return strings.TrimSpace(string(v)) +} + +// UnmarshalJSON accepts quoted strings and numeric IDs from clients. +func (v *StringValue) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = "" + return nil + } + if strings.HasPrefix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + *v = StringValue(strings.TrimSpace(unquoted)) + return nil + } + *v = StringValue(raw) + return nil +} + +// AgoraErrorReportRequest is the client report payload for Agora errors. +type AgoraErrorReportRequest struct { + RoomID StringValue `json:"roomId"` + AgoraUID StringValue `json:"agoraUid"` + ErrorCodeType string `json:"errorCodeType"` + ConnectionChangedReasonType string `json:"connectionChangedReasonType"` + ReasonType string `json:"reasonType"` + Reason string `json:"reason"` + Timeout *bool `json:"timeout"` + IsTimeout *bool `json:"isTimeout"` + TokenRequestSuccess *bool `json:"tokenRequestSuccess"` + TokenSuccess *bool `json:"tokenSuccess"` + NetworkType string `json:"networkType"` +} + +// AgoraErrorReportContext carries request metadata captured at the router layer. +type AgoraErrorReportContext struct { + ReqClient string + ReqAppIntel string + ClientIP string + UserAgent string + RawPayload string +} + +// AgoraErrorReportResponse is returned after a report is persisted. +type AgoraErrorReportResponse struct { + ID string `json:"id"` + BannedByServer bool `json:"bannedByServer"` +} + +// AgoraErrorLogPageResponse is the admin page response. +type AgoraErrorLogPageResponse struct { + Records []AgoraErrorLogItem `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +// AgoraErrorLogItem is an admin-readable Agora error log. +type AgoraErrorLogItem struct { + ID string `json:"id"` + SysOrigin string `json:"sysOrigin"` + UserID string `json:"userId"` + RoomID string `json:"roomId"` + AgoraUID string `json:"agoraUid"` + ErrorCodeType string `json:"errorCodeType"` + ConnectionChangedReasonType string `json:"connectionChangedReasonType"` + BannedByServer bool `json:"bannedByServer"` + IsTimeout bool `json:"isTimeout"` + TokenRequestSuccess *bool `json:"tokenRequestSuccess"` + NetworkType string `json:"networkType"` + ReqClient string `json:"reqClient"` + ReqAppIntel string `json:"reqAppIntel"` + ClientIP string `json:"clientIp"` + UserAgent string `json:"userAgent"` + RawPayload string `json:"rawPayload"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + +func normalizePage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func normalizeSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +func trimMax(value string, max int) string { + value = strings.TrimSpace(value) + if max > 0 && len(value) > max { + return value[:max] + } + return value +} + +func boolValue(primary *bool, aliases ...*bool) bool { + if primary != nil { + return *primary + } + for _, alias := range aliases { + if alias != nil { + return *alias + } + } + return false +} + +func boolPtrValue(primary *bool, aliases ...*bool) *bool { + if primary != nil { + return primary + } + for _, alias := range aliases { + if alias != nil { + return alias + } + } + return nil +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func isBannedByServer(reason string) bool { + normalized := strings.ToLower(strings.TrimSpace(reason)) + if normalized == "" { + return false + } + return normalized == "3" || + strings.Contains(normalized, "bannedbyserver") || + strings.Contains(normalized, "banned_by_server") || + strings.Contains(normalized, "banned by server") +} + +func formatDateTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(adminTimeLayout) +} + +func parseAdminTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) { + startRaw = strings.TrimSpace(startRaw) + endRaw = strings.TrimSpace(endRaw) + now := time.Now() + if startRaw == "" && endRaw == "" { + return now.AddDate(0, 0, -7), now, nil + } + + var start time.Time + var end time.Time + var err error + if startRaw != "" { + start, err = parseAdminTime(startRaw, false) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if endRaw != "" { + end, err = parseAdminTime(endRaw, true) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if startRaw == "" { + start = end.AddDate(0, 0, -7) + } + if endRaw == "" { + end = start.AddDate(0, 0, 7) + } + if end.Before(start) { + return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime") + } + return start, end, nil +} + +func parseAdminTime(raw string, endOfDay bool) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + return time.UnixMilli(millis), nil + } + for _, layout := range []string{ + adminTimeLayout, + time.RFC3339, + "2006-01-02T15:04:05.000Z07:00", + "2006-01-02", + } { + var parsed time.Time + var err error + if strings.Contains(layout, "Z07:00") || layout == time.RFC3339 { + parsed, err = time.Parse(layout, raw) + } else { + parsed, err = time.ParseInLocation(layout, raw, time.Local) + } + if err == nil { + if layout == "2006-01-02" && endOfDay { + parsed = parsed.Add(24*time.Hour - time.Nanosecond) + } + return parsed, nil + } + } + return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw)) +} diff --git a/internal/service/userbadge/current.go b/internal/service/userbadge/current.go new file mode 100644 index 0000000..ad592f2 --- /dev/null +++ b/internal/service/userbadge/current.go @@ -0,0 +1,223 @@ +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 + } + return s.currentForUser(ctx, sysOrigin, user.UserID) +} + +// Other returns another user's badge list in the current authenticated sysOrigin. +func (s *Service) Other(ctx context.Context, viewer AuthUser, targetUserID int64) (*CurrentBadgeListResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable") + } + sysOrigin, err := normalizeUser(viewer) + if err != nil { + return nil, err + } + if targetUserID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_target_user_id", "target userId is required") + } + return s.currentForUser(ctx, sysOrigin, targetUserID) +} + +func (s *Service) currentForUser(ctx context.Context, sysOrigin string, userID int64) (*CurrentBadgeListResponse, error) { + now := s.now() + longBadges, vipBadgeID, err := s.loadLongBadges(ctx, sysOrigin, userID, now) + if err != nil { + return nil, err + } + shortBadges, err := s.loadShortBadges(ctx, sysOrigin, 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: 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..f449ab4 --- /dev/null +++ b/internal/service/userbadge/current_test.go @@ -0,0 +1,188 @@ +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 TestOtherBadgesUsesTargetUser(t *testing.T) { + db := newUserBadgeTestDB(t) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + viewerID := int64(42) + targetID := int64(99) + seedBadgeConfig(t, db, 201, badgeSourceTypeActivity, "Viewer Badge", "VIEWER_BADGE", 1) + seedBadgeConfig(t, db, 202, badgeSourceTypeAchievement, "Target Badge", "TARGET_BADGE", 2) + for _, badgeID := range []int64{201, 202} { + if err := db.Create(&model.SysBadgePictureConfig{ + ID: badgeID + 1000, + BadgeConfigID: badgeID, + SysOrigin: "LIKEI", + SelectURL: "https://cdn.example.com/" + formatID(badgeID) + ".png", + }).Error; err != nil { + t.Fatalf("create picture %d: %v", badgeID, err) + } + } + backpacks := []model.UserBadgeBackpack{ + {ID: 3001, UserID: viewerID, BadgeID: 201, ExpireType: "TEMPORARY", ExpireTime: now.Add(2 * time.Hour), UseProps: true, UpdateTime: now}, + {ID: 3002, UserID: targetID, BadgeID: 202, ExpireType: "TEMPORARY", ExpireTime: now.Add(2 * time.Hour), UseProps: true, UpdateTime: now.Add(time.Minute)}, + } + 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.Other(context.Background(), common.AuthUser{UserID: viewerID, SysOrigin: "LIKEI"}, targetID) + if err != nil { + t.Fatalf("Other() error = %v", err) + } + if resp.UserID != targetID { + t.Fatalf("UserID = %d, want target %d", resp.UserID, targetID) + } + if len(resp.ShortBadges) != 1 || resp.ShortBadges[0].BadgeID != "202" { + t.Fatalf("short badges = %#v, want target badge 202", resp.ShortBadges) + } +} + +func TestOtherBadgesRejectsInvalidTargetUser(t *testing.T) { + service := NewService(newUserBadgeTestDB(t)) + _, err := service.Other(context.Background(), common.AuthUser{UserID: 42, SysOrigin: "LIKEI"}, 0) + if err == nil { + t.Fatalf("Other() error = nil, want invalid target user") + } + appErr, ok := err.(*AppError) + if !ok || appErr.Code != "invalid_target_user_id" { + t.Fatalf("error = %#v, want invalid_target_user_id", 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..5847612 --- /dev/null +++ b/internal/service/userbadge/types.go @@ -0,0 +1,135 @@ +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) +} + +// ParseUserID parses a userId query parameter. +func ParseUserID(raw string) int64 { + value := strings.TrimSpace(raw) + if value == "" { + return 0 + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0 + } + return parsed +} + +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..a71a064 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) @@ -264,6 +265,12 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe } else if amount <= 0 { amount = 1 } + rewardCopies := item.RewardCopies + if scene != rewardSceneInRoom { + rewardCopies = 1 + } else if rewardCopies <= 0 { + rewardCopies = 1 + } var row model.VoiceRoomRocketRewardConfig if item.ID > 0 { if err := tx.Where("id = ? AND sys_origin = ?", item.ID, sysOrigin).First(&row).Error; err != nil { @@ -288,6 +295,7 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe row.RewardCover = strings.TrimSpace(item.RewardCover) row.RewardAmount = amount row.ExpireDays = item.ExpireDays + row.RewardCopies = rewardCopies row.Weight = item.Weight row.Sort = item.Sort row.Enabled = item.Enabled diff --git a/internal/service/voiceroomrocket/config_test.go b/internal/service/voiceroomrocket/config_test.go new file mode 100644 index 0000000..597fd50 --- /dev/null +++ b/internal/service/voiceroomrocket/config_test.go @@ -0,0 +1,85 @@ +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) + } +} + +func TestGetStatusReturnsRocketResourcesToApp(t *testing.T) { + service, _ := newTestService(t) + ctx := context.Background() + + _, 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) + } + + status, err := service.GetStatus(ctx, AuthUser{SysOrigin: "LIKEI"}, 9001) + if err != nil { + t.Fatalf("GetStatus error = %v", err) + } + if status.PreviewRocketURL != "https://cdn.example.com/preview-rocket.png" { + t.Fatalf("status previewRocketUrl = %q", status.PreviewRocketURL) + } + if status.RocketIconURL != "https://cdn.example.com/rocket-icon.png" { + t.Fatalf("status rocketIconUrl = %q", status.RocketIconURL) + } + if status.RocketAnimationURL != "https://cdn.example.com/rocket-launch.svga" { + t.Fatalf("status rocketAnimationUrl = %q", status.RocketAnimationURL) + } + if len(status.Levels) != 1 || status.Levels[0].PreviewRocketURL != status.PreviewRocketURL { + t.Fatalf("status levels = %+v", status.Levels) + } +} diff --git a/internal/service/voiceroomrocket/event_test.go b/internal/service/voiceroomrocket/event_test.go index 292e00a..65a8981 100644 --- a/internal/service/voiceroomrocket/event_test.go +++ b/internal/service/voiceroomrocket/event_test.go @@ -176,7 +176,7 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10) seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5) - seedRewardConfig(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1) + seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 3) roomID := int64(9001) for _, userID := range []int64{1001, 1002, 1003, 1004} { @@ -255,6 +255,67 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { } } +func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) { + gateway := newFakeRocketGateway() + service, db := newTestService(t) + service.rewardGateway = gateway + + ctx := context.Background() + badgeID := int64(3001) + expireDays := 14 + record := model.VoiceRoomRocketRewardRecord{ + ID: 8801, + LaunchID: 9901, + LaunchNo: "launch-badge", + SysOrigin: "LIKEI", + RoomID: 9001, + DayKey: "20260515", + RoundNo: 1, + Level: 1, + UserID: 1001, + RewardScene: rewardSceneTop1, + RewardConfigID: 7701, + RewardType: rewardTypeBadge, + RewardItemID: &badgeID, + RewardName: "activity badge", + RewardAmount: 1, + ExpireDays: &expireDays, + GrantEventID: "VOICE_ROOM_ROCKET:launch-badge:TOP1:1001:7701", + GrantStatus: rewardStatusPending, + PopupStatus: popupStatusUnread, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if err := db.Create(&record).Error; err != nil { + t.Fatalf("seed reward record: %v", err) + } + + granted, err := service.ProcessPendingRewards(ctx, 10) + if err != nil { + t.Fatalf("ProcessPendingRewards() error = %v", err) + } + if granted != 1 { + t.Fatalf("granted = %d, want 1", granted) + } + if len(gateway.badges) != 1 { + t.Fatalf("badge grants = %d, want 1", len(gateway.badges)) + } + if gateway.badges[0] != (fakeBadgeGrant{userID: 1001, badgeID: badgeID, days: expireDays}) { + t.Fatalf("badge grant = %+v, want user 1001 badge %d days %d", gateway.badges[0], badgeID, expireDays) + } + if len(gateway.props) != 0 { + t.Fatalf("props grants = %d, want 0", len(gateway.props)) + } + + var latest model.VoiceRoomRocketRewardRecord + if err := db.Where("id = ?", record.ID).First(&latest).Error; err != nil { + t.Fatalf("load reward record: %v", err) + } + if latest.GrantStatus != rewardStatusSuccess { + t.Fatalf("grant status = %s, want %s", latest.GrantStatus, rewardStatusSuccess) + } +} + func TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) { gateway := newFakeRocketGateway() service, db, _, mr := newTestServiceWithRedis(t, gateway) @@ -336,11 +397,106 @@ func TestPickInRoomRewardCanBeEmpty(t *testing.T) { } } +func TestGrantGoldRewardUsesWalletRocketRewardOrigin(t *testing.T) { + service, _ := newTestService(t) + gateway := newFakeRocketGateway() + service.wallet = gateway + record := model.VoiceRoomRocketRewardRecord{ + UserID: 1001, + SysOrigin: "LIKEI", + Level: 1, + RewardScene: rewardSceneInRoom, + RewardType: rewardTypeGold, + RewardAmount: 100, + GrantEventID: "VOICE_ROOM_ROCKET:launch-1:IN_ROOM:1001:1", + } + + if err := service.grantGoldReward(context.Background(), record); err != nil { + t.Fatalf("grantGoldReward() error = %v", err) + } + walletEventID := walletGoldRewardEventID(record.GrantEventID) + cmd, ok := gateway.goldEvents[walletEventID] + if !ok { + t.Fatalf("gold wallet event was not created") + } + if len(cmd.EventID) > 50 { + t.Fatalf("wallet event id length = %d, want <= 50: %s", len(cmd.EventID), cmd.EventID) + } + if cmd.Origin != walletOriginRocketReward { + t.Fatalf("wallet origin = %q, want %q", cmd.Origin, walletOriginRocketReward) + } + if cmd.CustomizeOrigin != "" { + t.Fatalf("customize origin = %q, want empty", cmd.CustomizeOrigin) + } + if cmd.CustomizeOriginDesc != walletOriginRocketRewardDesc { + t.Fatalf("wallet origin desc = %q, want %q", cmd.CustomizeOriginDesc, walletOriginRocketRewardDesc) + } +} + +func TestRewardGrantEventIDFitsWalletAssetRecord(t *testing.T) { + eventID := rewardGrantEventID("VOICE_ROOM_ROCKET_LAUNCH:20260515:9001:ROUND:1:LEVEL:6:EXTRA-LONG", rewardSceneInRoom, 1001001001001, 9009009009001) + if len(eventID) > 50 { + t.Fatalf("grant event id length = %d, want <= 50: %s", len(eventID), eventID) + } + if eventID != rewardGrantEventID("VOICE_ROOM_ROCKET_LAUNCH:20260515:9001:ROUND:1:LEVEL:6:EXTRA-LONG", rewardSceneInRoom, 1001001001001, 9009009009001) { + t.Fatalf("grant event id must be deterministic") + } +} + +func TestInRoomRewardCopiesLimitSelection(t *testing.T) { + service, _ := newTestService(t) + service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 10 + launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-copies"} + audience := []roomAudienceUser{ + {UserID: 1001}, + {UserID: 1002}, + {UserID: 1003}, + {UserID: 1004}, + } + rewards := []model.VoiceRoomRocketRewardConfig{ + { + ID: 1, + RewardScene: rewardSceneInRoom, + RewardType: rewardTypeGold, + RewardName: "金币", + RewardAmount: 100, + RewardCopies: 2, + Weight: 1, + }, + } + + selected := service.selectInRoomRewardUsers(launch, audience, rewards) + if len(selected) != 2 { + t.Fatalf("selected users = %+v, want 2 users", selected) + } + picked := service.pickInRoomRewardsForUsers(launch, rewards, selected) + if len(picked) != 2 { + t.Fatalf("picked rewards = %+v, want 2 users with rewards", picked) + } +} + func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { gateway := newFakeRocketGateway() service, db, rdb, mr := newTestServiceWithRedis(t, gateway) defer mr.Close() seedRocketConfig(t, db) + now := time.Now() + if err := db.Create(&model.VoiceRoomRocketLevelConfig{ + ID: 991, + SysOrigin: "LIKEI", + 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, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed level config: %v", err) + } fakeIM := &fakeRocketIM{} service.SetIMGateway(fakeIM) @@ -360,6 +516,23 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" { t.Fatalf("im messages = %+v", fakeIM.messages) } + body, ok := fakeIM.messages[0].body.(map[string]any) + if !ok { + t.Fatalf("im body = %#v", fakeIM.messages[0].body) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("im data = %#v", body["data"]) + } + if data["previewRocketUrl"] != "https://cdn.example.com/preview-rocket.png" { + t.Fatalf("previewRocketUrl = %#v", data["previewRocketUrl"]) + } + if data["rocketIconUrl"] != "https://cdn.example.com/rocket-icon.png" { + t.Fatalf("rocketIconUrl = %#v", data["rocketIconUrl"]) + } + if data["rocketAnimationUrl"] != "https://cdn.example.com/rocket-launch.svga" { + t.Fatalf("rocketAnimationUrl = %#v", data["rocketAnimationUrl"]) + } count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result() if err != nil { t.Fatalf("xlen broadcast: %v", err) @@ -457,10 +630,11 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) { Rewards: []RewardConfigPayload{ { Level: 1, - RewardScene: rewardSceneTop1, + RewardScene: rewardSceneInRoom, RewardType: rewardTypeGold, RewardName: "new gold", RewardAmount: 99, + RewardCopies: 10, Enabled: true, Sort: 1, }, @@ -469,8 +643,8 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) { if err != nil { t.Fatalf("SaveRewardConfigs(replace) error = %v", err) } - if len(saved) != 1 || saved[0].RewardAmount != 99 { - t.Fatalf("saved rewards = %+v, want one amount 99", saved) + if len(saved) != 1 || saved[0].RewardAmount != 99 || saved[0].RewardCopies != 10 { + t.Fatalf("saved rewards = %+v, want one amount 99 and 10 copies", saved) } var level1Count int64 @@ -570,7 +744,13 @@ func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service type fakeRocketGateway struct { goldEvents map[string]integration.GoldReceiptCommand props []integration.GivePropsBackpackRequest - badges []int64 + badges []fakeBadgeGrant +} + +type fakeBadgeGrant struct { + userID int64 + badgeID int64 + days int } type fakeRocketIM struct { @@ -637,7 +817,7 @@ func (g *fakeRocketGateway) SwitchUseProps(ctx context.Context, userID int64, pr } func (g *fakeRocketGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { - g.badges = append(g.badges, badgeID) + g.badges = append(g.badges, fakeBadgeGrant{userID: userID, badgeID: badgeID, days: days}) return nil } @@ -711,6 +891,10 @@ func seedLevelConfigs(t *testing.T, db *gorm.DB, energy []int64) { } func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64) { + seedRewardConfigWithCopies(t, db, level, scene, rewardType, amount, 1) +} + +func seedRewardConfigWithCopies(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64, copies int) { t.Helper() now := time.Now() row := model.VoiceRoomRocketRewardConfig{ @@ -721,6 +905,7 @@ func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, reward RewardType: rewardType, RewardName: scene + " reward", RewardAmount: amount, + RewardCopies: copies, Enabled: true, CreateTime: now, UpdateTime: now, diff --git a/internal/service/voiceroomrocket/in_room_reward.go b/internal/service/voiceroomrocket/in_room_reward.go index ce23d87..86c0e0f 100644 --- a/internal/service/voiceroomrocket/in_room_reward.go +++ b/internal/service/voiceroomrocket/in_room_reward.go @@ -88,7 +88,11 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string if err != nil { return err } - selected := s.selectInRoomRewardUsers(launch, audience) + rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) + if err != nil { + return err + } + selected := s.selectInRoomRewardUsers(launch, audience, rewards) if err := s.saveAudienceSnapshot(ctx, tx, launch, audience, selected, now); err != nil { return err } @@ -100,12 +104,9 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string return nil } - rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) - if err != nil { - return err - } + pickedRewards := s.pickInRoomRewardsForUsers(launch, rewards, selected) for _, userID := range selected { - for _, reward := range s.pickInRoomRewardsForUser(launch, rewards, userID) { + for _, reward := range pickedRewards[userID] { record, err := s.createRewardRecord(ctx, tx, launch, reward, userID, now) if err != nil { return err @@ -201,11 +202,8 @@ func (s *Service) loadInRoomRewardConfigs(ctx context.Context, sysOrigin string, return result, nil } -func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser) []int64 { - limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit - if limit <= 0 { - limit = defaultInRoomRewardUserLimit - } +func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser, rewards []model.VoiceRoomRocketRewardConfig) []int64 { + limit := s.inRoomRewardSelectionLimit(rewards) if len(audience) == 0 { return nil } @@ -228,37 +226,80 @@ func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, au return selected } -func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { - if len(rewards) == 0 { - return nil +func (s *Service) inRoomRewardSelectionLimit(rewards []model.VoiceRoomRocketRewardConfig) int { + totalCopies := 0 + for _, reward := range rewards { + if !strings.EqualFold(strings.TrimSpace(reward.RewardScene), rewardSceneInRoom) { + continue + } + totalCopies += positiveOrDefault(reward.RewardCopies, 1) } - weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) - totalWeight := 0 + if totalCopies > 0 { + return totalCopies + } + limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit + if limit <= 0 { + limit = defaultInRoomRewardUserLimit + } + return limit +} + +func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { + picked := s.pickInRoomRewardsForUsers(launch, rewards, []int64{userID}) + return picked[userID] +} + +func (s *Service) pickInRoomRewardsForUsers(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userIDs []int64) map[int64][]model.VoiceRoomRocketRewardConfig { + result := make(map[int64][]model.VoiceRoomRocketRewardConfig, len(userIDs)) + if len(rewards) == 0 { + return result + } + + type rewardCandidate struct { + reward model.VoiceRoomRocketRewardConfig + remaining int + } + weighted := make([]rewardCandidate, 0, len(rewards)) for _, reward := range rewards { weight := reward.Weight if weight <= 0 { weight = 1 } reward.Weight = weight - weighted = append(weighted, reward) - totalWeight += weight + weighted = append(weighted, rewardCandidate{ + reward: reward, + remaining: positiveOrDefault(reward.RewardCopies, 1), + }) } - if totalWeight <= 0 { - return nil - } - rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) - point := rng.Intn(totalWeight) + 1 - acc := 0 - for _, reward := range weighted { - acc += reward.Weight - if point <= acc { - if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) { - return nil + + for _, userID := range userIDs { + totalWeight := 0 + for _, candidate := range weighted { + if candidate.remaining > 0 { + totalWeight += candidate.reward.Weight + } + } + if totalWeight <= 0 { + continue + } + rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) + point := rng.Intn(totalWeight) + 1 + acc := 0 + for idx := range weighted { + if weighted[idx].remaining <= 0 { + continue + } + acc += weighted[idx].reward.Weight + if point <= acc { + weighted[idx].remaining-- + if !strings.EqualFold(strings.TrimSpace(weighted[idx].reward.RewardType), rewardTypeNone) { + result[userID] = []model.VoiceRoomRocketRewardConfig{weighted[idx].reward} + } + break } - return []model.VoiceRoomRocketRewardConfig{reward} } } - return nil + return result } func seedFromStrings(parts ...string) int64 { diff --git a/internal/service/voiceroomrocket/mapper.go b/internal/service/voiceroomrocket/mapper.go index 38c4939..aec9892 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, @@ -159,6 +160,7 @@ func rewardPayload(row model.VoiceRoomRocketRewardConfig) RewardConfigPayload { RewardCover: row.RewardCover, RewardAmount: row.RewardAmount, ExpireDays: row.ExpireDays, + RewardCopies: positiveOrDefault(row.RewardCopies, 1), Weight: row.Weight, Sort: row.Sort, Enabled: row.Enabled, diff --git a/internal/service/voiceroomrocket/notify.go b/internal/service/voiceroomrocket/notify.go index 71ef797..bf92a7f 100644 --- a/internal/service/voiceroomrocket/notify.go +++ b/internal/service/voiceroomrocket/notify.go @@ -120,18 +120,29 @@ func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocket if launch.IgniteUserID != nil { triggerUserID = strconv.FormatInt(*launch.IgniteUserID, 10) } + previewRocketURL := "" + rocketIconURL := "" + rocketAnimationURL := "" + if levelConfig, err := s.loadLevelConfig(ctx, launch.SysOrigin, launch.Level); err == nil { + previewRocketURL = levelConfig.PreviewRocketURL + rocketIconURL = levelConfig.RocketIconURL + rocketAnimationURL = levelConfig.RocketAnimationURL + } body := map[string]any{ "type": imTypeLaunchBroadcast, "data": map[string]any{ - "roomId": strconv.FormatInt(launch.RoomID, 10), - "roomAccount": launch.RoomAccount, - "roomName": roomName, - "roundNo": launch.RoundNo, - "level": launch.Level, - "durationSeconds": durationSeconds, - "triggerUser": triggerUser, - "triggerUserId": triggerUserID, - "launchNo": launch.LaunchNo, + "roomId": strconv.FormatInt(launch.RoomID, 10), + "roomAccount": launch.RoomAccount, + "roomName": roomName, + "roundNo": launch.RoundNo, + "level": launch.Level, + "durationSeconds": durationSeconds, + "previewRocketUrl": previewRocketURL, + "rocketIconUrl": rocketIconURL, + "rocketAnimationUrl": rocketAnimationURL, + "triggerUser": triggerUser, + "triggerUserId": triggerUserID, + "launchNo": launch.LaunchNo, }, } if s.repo.Redis != nil { diff --git a/internal/service/voiceroomrocket/reward.go b/internal/service/voiceroomrocket/reward.go index 6fcd1f8..f468309 100644 --- a/internal/service/voiceroomrocket/reward.go +++ b/internal/service/voiceroomrocket/reward.go @@ -2,6 +2,7 @@ package voiceroomrocket import ( "context" + "crypto/sha1" "errors" "fmt" "net/http" @@ -187,7 +188,8 @@ func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocket if s.wallet == nil { return NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") } - exists, err := s.wallet.ExistsGoldEvent(ctx, row.GrantEventID) + walletEventID := walletGoldRewardEventID(row.GrantEventID) + exists, err := s.wallet.ExistsGoldEvent(ctx, walletEventID) if err != nil { return err } @@ -196,13 +198,13 @@ func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocket ReceiptType: "INCOME", UserID: row.UserID, SysOrigin: row.SysOrigin, - EventID: row.GrantEventID, + EventID: walletEventID, Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level), Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount), CloseDelayAsset: true, OpUserType: "BACK", - CustomizeOrigin: "VOICE_ROOM_ROCKET", - CustomizeOriginDesc: "Voice Room Rocket Reward", + Origin: walletOriginRocketReward, + CustomizeOriginDesc: walletOriginRocketRewardDesc, }); err != nil { return err } @@ -341,5 +343,19 @@ func (s *Service) acquireRewardLock(ctx context.Context, rewardRecordID int64) ( } func rewardGrantEventID(launchNo, scene string, userID int64, rewardConfigID int64) string { - return fmt.Sprintf("VOICE_ROOM_ROCKET:%s:%s:%d:%d", launchNo, strings.ToUpper(strings.TrimSpace(scene)), userID, rewardConfigID) + raw := fmt.Sprintf("%s:%s:%d:%d", launchNo, strings.ToUpper(strings.TrimSpace(scene)), userID, rewardConfigID) + return "VRR:" + hashString(raw) +} + +func walletGoldRewardEventID(grantEventID string) string { + grantEventID = strings.TrimSpace(grantEventID) + if len(grantEventID) > 0 && len(grantEventID) <= 50 { + return grantEventID + } + return "VRR:" + hashString(grantEventID) +} + +func hashString(value string) string { + sum := sha1.Sum([]byte(value)) + return fmt.Sprintf("%x", sum) } diff --git a/internal/service/voiceroomrocket/status.go b/internal/service/voiceroomrocket/status.go index 9b130cf..9a2e319 100644 --- a/internal/service/voiceroomrocket/status.go +++ b/internal/service/voiceroomrocket/status.go @@ -58,6 +58,9 @@ func (s *Service) GetStatus(ctx context.Context, user AuthUser, roomID int64) (* DisplayPercent: displayPercent, LaunchCountdownSeconds: countdownSeconds, Shake: shake, + PreviewRocketURL: currentLevelConfig.PreviewRocketURL, + RocketIconURL: currentLevelConfig.RocketIconURL, + RocketAnimationURL: currentLevelConfig.RocketAnimationURL, Levels: buildLevelPayloads(levels), RewardPreview: rewardPreviewPayload(rewards), RocketKings: kings, diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go index 2efdf38..8f28767 100644 --- a/internal/service/voiceroomrocket/types.go +++ b/internal/service/voiceroomrocket/types.go @@ -77,6 +77,9 @@ const ( propsTypeGift = "GIFT" propsTypeBadge = "BADGE" + walletOriginRocketReward = "ROCKET_REWARD" + walletOriginRocketRewardDesc = "Rocket reward" + imTypeStatusUpdate = "VOICE_ROOM_ROCKET_STATUS_UPDATE" imTypeLaunchBroadcast = "VOICE_ROOM_ROCKET_LAUNCH_BROADCAST" imTypeRewardPopup = "VOICE_ROOM_ROCKET_REWARD_POPUP" @@ -315,6 +318,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"` @@ -332,6 +336,7 @@ type RewardConfigPayload struct { RewardCover string `json:"rewardCover,omitempty"` RewardAmount int64 `json:"rewardAmount"` ExpireDays *int `json:"expireDays,omitempty"` + RewardCopies int `json:"rewardCopies"` Weight int `json:"weight"` Sort int `json:"sort"` Enabled bool `json:"enabled"` @@ -398,6 +403,9 @@ type StatusResponse struct { DisplayPercent int `json:"displayPercent"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"` Shake bool `json:"shake"` + PreviewRocketURL string `json:"previewRocketUrl,omitempty"` + RocketIconURL string `json:"rocketIconUrl,omitempty"` + RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"` Levels []LevelConfigPayload `json:"levels"` RewardPreview RewardPreviewResponse `json:"rewardPreview"` RocketKings []KingRecord `json:"rocketKings"` diff --git a/migrations/032_voice_room_rocket.sql b/migrations/032_voice_room_rocket.sql index c063383..5049d0f 100644 --- a/migrations/032_voice_room_rocket.sql +++ b/migrations/032_voice_room_rocket.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS voice_room_rocket_config ( - id BIGINT NOT NULL PRIMARY KEY, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区', @@ -10,186 +10,188 @@ CREATE TABLE IF NOT EXISTS voice_room_rocket_config ( in_room_reward_delay_seconds INT NOT NULL DEFAULT 10 COMMENT '在房间奖励延迟窗口秒数', energy_rule_json JSON DEFAULT NULL COMMENT '礼物折算规则', rule_text JSON DEFAULT NULL COMMENT '规则说明多语言文案', - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_config (sys_origin) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭系统配置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭系统配置'; CREATE TABLE IF NOT EXISTS voice_room_rocket_level_config ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, - level INT NOT NULL COMMENT 'Lv.1-Lv.6', + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + 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 '抖动阈值百分比', - rocket_icon_url VARCHAR(512) DEFAULT NULL, - rocket_animation_url VARCHAR(512) DEFAULT NULL, - progress_bar_url VARCHAR(512) DEFAULT NULL, - enabled TINYINT(1) NOT NULL DEFAULT 1, - sort INT NOT NULL DEFAULT 0, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + preview_rocket_url VARCHAR(512) DEFAULT NULL COMMENT '预览火箭资源URL', + rocket_icon_url VARCHAR(512) DEFAULT NULL COMMENT '火箭图标资源URL', + rocket_animation_url VARCHAR(512) DEFAULT NULL COMMENT '火箭发射动画资源URL', + progress_bar_url VARCHAR(512) DEFAULT NULL COMMENT '进度条资源URL(历史字段)', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + sort INT NOT NULL DEFAULT 0 COMMENT '排序值,升序', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_level (sys_origin, level) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭等级配置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭等级配置'; CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_config ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', level INT NOT NULL COMMENT '适用等级', reward_scene VARCHAR(32) NOT NULL COMMENT 'TOP1/IGNITE/IN_ROOM', - reward_type VARCHAR(32) NOT NULL COMMENT 'GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE', + reward_type VARCHAR(32) NOT NULL COMMENT 'GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE/NONE', reward_item_id BIGINT DEFAULT NULL COMMENT '道具、礼物、权益等资源ID', - reward_name VARCHAR(128) NOT NULL, - reward_cover VARCHAR(512) DEFAULT NULL, + reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称', + reward_cover VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL', reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数', - weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机权重;0 表示固定发放', - sort INT NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + reward_copies INT NOT NULL DEFAULT 1 COMMENT 'IN_ROOM 本奖励每次发射可发放份数', + weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机抽取权重,小于等于0按1处理', + sort INT NOT NULL DEFAULT 0 COMMENT '排序值,升序', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY idx_voice_room_rocket_reward_scene (sys_origin, level, reward_scene, enabled) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭奖励配置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭奖励配置'; CREATE TABLE IF NOT EXISTS voice_room_rocket_status ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', - round_no INT NOT NULL DEFAULT 1, - current_level INT NOT NULL DEFAULT 1, - current_energy BIGINT NOT NULL DEFAULT 0, - need_energy BIGINT NOT NULL, + round_no INT NOT NULL DEFAULT 1 COMMENT '当天第几轮火箭', + current_level INT NOT NULL DEFAULT 1 COMMENT '当前等级', + current_energy BIGINT NOT NULL DEFAULT 0 COMMENT '当前等级已累计能量', + need_energy BIGINT NOT NULL COMMENT '当前等级所需能量', status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHING', - level_start_time DATETIME NOT NULL, - last_event_time DATETIME DEFAULT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + level_start_time DATETIME NOT NULL COMMENT '当前等级开始时间', + last_event_time DATETIME DEFAULT NULL COMMENT '最近一次有效送礼事件时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_status (sys_origin, room_id, day_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭当前状态'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭当前状态'; CREATE TABLE IF NOT EXISTS voice_room_rocket_level_round ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, - day_key VARCHAR(16) NOT NULL, - round_no INT NOT NULL, - level INT NOT NULL, - need_energy BIGINT NOT NULL, - final_energy BIGINT NOT NULL DEFAULT 0, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL COMMENT '当天第几轮火箭', + level INT NOT NULL COMMENT '火箭等级', + need_energy BIGINT NOT NULL COMMENT '本级所需能量', + final_energy BIGINT NOT NULL DEFAULT 0 COMMENT '本级最终累计能量', status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHED', - top1_user_id BIGINT DEFAULT NULL, - ignite_user_id BIGINT DEFAULT NULL, + top1_user_id BIGINT DEFAULT NULL COMMENT '本级贡献第一用户ID', + ignite_user_id BIGINT DEFAULT NULL COMMENT '点火用户ID', reach_time DATETIME DEFAULT NULL COMMENT '能量达标时间', launch_time DATETIME DEFAULT NULL COMMENT '发射时间', - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_level_round (sys_origin, room_id, day_key, round_no, level), KEY idx_voice_room_rocket_level_round_room (sys_origin, room_id, day_key, round_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭每轮每级快照'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭每轮每级快照'; CREATE TABLE IF NOT EXISTS voice_room_rocket_contribution ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, - day_key VARCHAR(16) NOT NULL, - round_no INT NOT NULL, - level INT NOT NULL, - user_id BIGINT NOT NULL, - score_energy BIGINT NOT NULL DEFAULT 0, - gift_count INT NOT NULL DEFAULT 0, - first_contribute_time DATETIME NOT NULL, - last_contribute_time DATETIME NOT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL COMMENT '当天第几轮火箭', + level INT NOT NULL COMMENT '火箭等级', + user_id BIGINT NOT NULL COMMENT '贡献用户ID', + score_energy BIGINT NOT NULL DEFAULT 0 COMMENT '计入榜单的能量值', + gift_count INT NOT NULL DEFAULT 0 COMMENT '计入火箭的送礼次数', + first_contribute_time DATETIME NOT NULL COMMENT '首次贡献时间', + last_contribute_time DATETIME NOT NULL COMMENT '最近贡献时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_contribution_user (sys_origin, room_id, day_key, round_no, level, user_id), KEY idx_voice_room_rocket_contribution_rank (sys_origin, room_id, day_key, round_no, level, score_energy, last_contribute_time) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭贡献榜'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭贡献榜'; CREATE TABLE IF NOT EXISTS voice_room_rocket_gift_event_log ( - id BIGINT NOT NULL PRIMARY KEY, - sys_origin VARCHAR(32) NOT NULL, - track_id VARCHAR(128) NOT NULL, - room_id BIGINT NOT NULL, - send_user_id BIGINT NOT NULL, - gift_id BIGINT DEFAULT NULL, - gift_type VARCHAR(32) DEFAULT NULL, - gift_tab VARCHAR(64) DEFAULT NULL, - raw_energy BIGINT NOT NULL DEFAULT 0, - accepted_energy BIGINT NOT NULL DEFAULT 0, - day_key VARCHAR(16) NOT NULL, - round_no INT NOT NULL, - level INT NOT NULL, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + track_id VARCHAR(128) NOT NULL COMMENT '送礼事件唯一追踪ID', + room_id BIGINT NOT NULL COMMENT '房间ID', + send_user_id BIGINT NOT NULL COMMENT '送礼用户ID', + gift_id BIGINT DEFAULT NULL COMMENT '礼物ID', + gift_type VARCHAR(32) DEFAULT NULL COMMENT '礼物类型', + gift_tab VARCHAR(64) DEFAULT NULL COMMENT '礼物分类', + raw_energy BIGINT NOT NULL DEFAULT 0 COMMENT '原始能量值', + accepted_energy BIGINT NOT NULL DEFAULT 0 COMMENT '实际计入火箭能量值', + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL COMMENT '当天第几轮火箭', + level INT NOT NULL COMMENT '火箭等级', status VARCHAR(32) NOT NULL COMMENT 'IGNORED/SUCCESS/FAILED', - raw_payload MEDIUMTEXT, - error_message VARCHAR(1024) DEFAULT NULL, - event_time DATETIME NOT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + raw_payload MEDIUMTEXT COMMENT '原始消息内容', + error_message VARCHAR(1024) DEFAULT NULL COMMENT '失败原因', + event_time DATETIME NOT NULL COMMENT '事件发生时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_track (track_id), KEY idx_voice_room_rocket_event_room (sys_origin, room_id, day_key, round_no, level) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭送礼事件日志'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭送礼事件日志'; CREATE TABLE IF NOT EXISTS voice_room_rocket_launch ( - id BIGINT NOT NULL PRIMARY KEY, - launch_no VARCHAR(64) NOT NULL, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, - room_account VARCHAR(64) DEFAULT NULL, - day_key VARCHAR(16) NOT NULL, - round_no INT NOT NULL, - level INT NOT NULL, - final_energy BIGINT NOT NULL, - top1_user_id BIGINT DEFAULT NULL, - ignite_user_id BIGINT DEFAULT NULL, - in_room_snapshot_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', - launch_time DATETIME NOT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + launch_no VARCHAR(64) NOT NULL COMMENT '发射编号', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', + room_account VARCHAR(64) DEFAULT NULL COMMENT '房间号', + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL COMMENT '当天第几轮火箭', + level INT NOT NULL COMMENT '火箭等级', + final_energy BIGINT NOT NULL COMMENT '发射时最终能量', + top1_user_id BIGINT DEFAULT NULL COMMENT '贡献第一用户ID', + ignite_user_id BIGINT DEFAULT NULL COMMENT '点火用户ID', + in_room_snapshot_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '在房用户快照状态,PENDING/SUCCESS/FAILED', + launch_time DATETIME NOT NULL COMMENT '发射时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_launch_no (launch_no), UNIQUE KEY uk_voice_room_rocket_launch_level (sys_origin, room_id, day_key, round_no, level), KEY idx_voice_room_rocket_launch_room (sys_origin, room_id, launch_time) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭发射记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭发射记录'; CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_record ( - id BIGINT NOT NULL PRIMARY KEY, - launch_id BIGINT NOT NULL, - launch_no VARCHAR(64) NOT NULL, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, - day_key VARCHAR(16) NOT NULL, - round_no INT NOT NULL, - level INT NOT NULL, - user_id BIGINT NOT NULL, - reward_scene VARCHAR(32) NOT NULL, - reward_config_id BIGINT NOT NULL, - reward_type VARCHAR(32) NOT NULL, - reward_item_id BIGINT DEFAULT NULL, - reward_name VARCHAR(128) NOT NULL, - reward_cover VARCHAR(512) DEFAULT NULL, - reward_amount BIGINT NOT NULL DEFAULT 1, - expire_days INT DEFAULT NULL, - grant_event_id VARCHAR(128) NOT NULL, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + launch_id BIGINT NOT NULL COMMENT '发射记录ID', + launch_no VARCHAR(64) NOT NULL COMMENT '发射编号', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL COMMENT '当天第几轮火箭', + level INT NOT NULL COMMENT '火箭等级', + user_id BIGINT NOT NULL COMMENT '获奖用户ID', + reward_scene VARCHAR(32) NOT NULL COMMENT '奖励场景,TOP1/IGNITE/IN_ROOM', + reward_config_id BIGINT NOT NULL COMMENT '奖励配置ID', + reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型,GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE/NONE', + reward_item_id BIGINT DEFAULT NULL COMMENT '奖励资源ID', + reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称', + reward_cover VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL', + reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', + expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数', + grant_event_id VARCHAR(128) NOT NULL COMMENT '发奖幂等事件ID', grant_status VARCHAR(32) NOT NULL COMMENT 'PENDING/SUCCESS/FAILED', popup_status VARCHAR(32) NOT NULL DEFAULT 'UNREAD' COMMENT 'UNREAD/READ', - error_message VARCHAR(1024) DEFAULT NULL, - grant_time DATETIME DEFAULT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + error_message VARCHAR(1024) DEFAULT NULL COMMENT '发奖失败原因', + grant_time DATETIME DEFAULT NULL COMMENT '发奖完成时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_voice_room_rocket_reward_event (grant_event_id), KEY idx_voice_room_rocket_reward_user (sys_origin, user_id, create_time), KEY idx_voice_room_rocket_reward_launch (launch_id, user_id), KEY idx_voice_room_rocket_reward_retry (grant_status, update_time) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭奖励记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭奖励记录'; CREATE TABLE IF NOT EXISTS voice_room_rocket_audience_snapshot ( - id BIGINT NOT NULL PRIMARY KEY, - launch_id BIGINT NOT NULL, - sys_origin VARCHAR(32) NOT NULL, - room_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - last_seen_time DATETIME NOT NULL, - selected TINYINT(1) NOT NULL DEFAULT 0, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID', + launch_id BIGINT NOT NULL COMMENT '发射记录ID', + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + room_id BIGINT NOT NULL COMMENT '房间ID', + user_id BIGINT NOT NULL COMMENT '在房用户ID', + last_seen_time DATETIME NOT NULL COMMENT '最近在线时间', + selected TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否被抽中发放在房奖励', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE KEY uk_voice_room_rocket_audience (launch_id, user_id), KEY idx_voice_room_rocket_audience_launch (launch_id, selected) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭在房间用户快照'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭在房间用户快照'; 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; diff --git a/migrations/038_voice_room_rocket_reward_copies.sql b/migrations/038_voice_room_rocket_reward_copies.sql new file mode 100644 index 0000000..39ca58a --- /dev/null +++ b/migrations/038_voice_room_rocket_reward_copies.sql @@ -0,0 +1,16 @@ +SET @current_schema := DATABASE(); + +SET @add_voice_room_rocket_reward_copies_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'voice_room_rocket_reward_config' + AND column_name = 'reward_copies' + ), + 'ALTER TABLE `voice_room_rocket_reward_config` ADD COLUMN `reward_copies` int NOT NULL DEFAULT 1 COMMENT ''IN_ROOM 本奖励每次发射可发放份数'' AFTER `expire_days`', + 'SELECT 1' +); +PREPARE add_voice_room_rocket_reward_copies_stmt FROM @add_voice_room_rocket_reward_copies_sql; +EXECUTE add_voice_room_rocket_reward_copies_stmt; +DEALLOCATE PREPARE add_voice_room_rocket_reward_copies_stmt; diff --git a/migrations/039_voice_room_rocket_chinese_comments.sql b/migrations/039_voice_room_rocket_chinese_comments.sql new file mode 100644 index 0000000..3d02ec8 --- /dev/null +++ b/migrations/039_voice_room_rocket_chinese_comments.sql @@ -0,0 +1,178 @@ +ALTER TABLE `voice_room_rocket_config` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭系统配置', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN `timezone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区', + MODIFY COLUMN `max_level` INT NOT NULL DEFAULT 6 COMMENT '最大等级', + MODIFY COLUMN `ranking_top_limit` INT NOT NULL DEFAULT 100 COMMENT '火箭王榜单展示人数', + MODIFY COLUMN `reward_record_keep_days` INT NOT NULL DEFAULT 35 COMMENT '用户奖励记录展示天数', + MODIFY COLUMN `broadcast_duration_seconds` INT NOT NULL DEFAULT 7 COMMENT '发射广播展示秒数', + MODIFY COLUMN `in_room_reward_delay_seconds` INT NOT NULL DEFAULT 10 COMMENT '在房间奖励延迟窗口秒数', + MODIFY COLUMN `energy_rule_json` JSON DEFAULT NULL COMMENT '礼物折算规则', + MODIFY COLUMN `rule_text` JSON DEFAULT NULL COMMENT '规则说明多语言文案', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_level_config` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭等级配置', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级,Lv.1-Lv.6', + MODIFY COLUMN `need_energy` BIGINT NOT NULL COMMENT '本级发射所需能量', + MODIFY COLUMN `shake_threshold_percent` DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '抖动阈值百分比', + MODIFY COLUMN `preview_rocket_url` VARCHAR(512) DEFAULT NULL COMMENT '预览火箭资源URL', + MODIFY COLUMN `rocket_icon_url` VARCHAR(512) DEFAULT NULL COMMENT '火箭图标资源URL', + MODIFY COLUMN `rocket_animation_url` VARCHAR(512) DEFAULT NULL COMMENT '火箭发射动画资源URL', + MODIFY COLUMN `progress_bar_url` VARCHAR(512) DEFAULT NULL COMMENT '进度条资源URL(历史字段)', + MODIFY COLUMN `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN `sort` INT NOT NULL DEFAULT 0 COMMENT '排序值,升序', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_reward_config` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭奖励配置', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `level` INT NOT NULL COMMENT '适用等级', + MODIFY COLUMN `reward_scene` VARCHAR(32) NOT NULL COMMENT '奖励场景,TOP1/IGNITE/IN_ROOM', + MODIFY COLUMN `reward_type` VARCHAR(32) NOT NULL COMMENT '奖励类型,GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE/NONE', + MODIFY COLUMN `reward_item_id` BIGINT DEFAULT NULL COMMENT '道具、礼物、权益等资源ID', + MODIFY COLUMN `reward_name` VARCHAR(128) NOT NULL COMMENT '奖励名称', + MODIFY COLUMN `reward_cover` VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL', + MODIFY COLUMN `reward_amount` BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', + MODIFY COLUMN `expire_days` INT DEFAULT NULL COMMENT '时效类奖励有效天数', + MODIFY COLUMN `reward_copies` INT NOT NULL DEFAULT 1 COMMENT 'IN_ROOM 本奖励每次发射可发放份数', + MODIFY COLUMN `weight` INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机抽取权重,小于等于0按1处理', + MODIFY COLUMN `sort` INT NOT NULL DEFAULT 0 COMMENT '排序值,升序', + MODIFY COLUMN `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_status` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭当前状态', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL DEFAULT 1 COMMENT '当天第几轮火箭', + MODIFY COLUMN `current_level` INT NOT NULL DEFAULT 1 COMMENT '当前等级', + MODIFY COLUMN `current_energy` BIGINT NOT NULL DEFAULT 0 COMMENT '当前等级已累计能量', + MODIFY COLUMN `need_energy` BIGINT NOT NULL COMMENT '当前等级所需能量', + MODIFY COLUMN `status` VARCHAR(32) NOT NULL COMMENT '状态,CHARGING/LAUNCHING', + MODIFY COLUMN `level_start_time` DATETIME NOT NULL COMMENT '当前等级开始时间', + MODIFY COLUMN `last_event_time` DATETIME DEFAULT NULL COMMENT '最近一次有效送礼事件时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_level_round` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭每轮每级快照', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL COMMENT '当天第几轮火箭', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级', + MODIFY COLUMN `need_energy` BIGINT NOT NULL COMMENT '本级所需能量', + MODIFY COLUMN `final_energy` BIGINT NOT NULL DEFAULT 0 COMMENT '本级最终累计能量', + MODIFY COLUMN `status` VARCHAR(32) NOT NULL COMMENT '状态,CHARGING/LAUNCHED', + MODIFY COLUMN `top1_user_id` BIGINT DEFAULT NULL COMMENT '本级贡献第一用户ID', + MODIFY COLUMN `ignite_user_id` BIGINT DEFAULT NULL COMMENT '点火用户ID', + MODIFY COLUMN `reach_time` DATETIME DEFAULT NULL COMMENT '能量达标时间', + MODIFY COLUMN `launch_time` DATETIME DEFAULT NULL COMMENT '发射时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_contribution` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭贡献榜', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL COMMENT '当天第几轮火箭', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级', + MODIFY COLUMN `user_id` BIGINT NOT NULL COMMENT '贡献用户ID', + MODIFY COLUMN `score_energy` BIGINT NOT NULL DEFAULT 0 COMMENT '计入榜单的能量值', + MODIFY COLUMN `gift_count` INT NOT NULL DEFAULT 0 COMMENT '计入火箭的送礼次数', + MODIFY COLUMN `first_contribute_time` DATETIME NOT NULL COMMENT '首次贡献时间', + MODIFY COLUMN `last_contribute_time` DATETIME NOT NULL COMMENT '最近贡献时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_gift_event_log` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭送礼事件日志', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `track_id` VARCHAR(128) NOT NULL COMMENT '送礼事件唯一追踪ID', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `send_user_id` BIGINT NOT NULL COMMENT '送礼用户ID', + MODIFY COLUMN `gift_id` BIGINT DEFAULT NULL COMMENT '礼物ID', + MODIFY COLUMN `gift_type` VARCHAR(32) DEFAULT NULL COMMENT '礼物类型', + MODIFY COLUMN `gift_tab` VARCHAR(64) DEFAULT NULL COMMENT '礼物分类', + MODIFY COLUMN `raw_energy` BIGINT NOT NULL DEFAULT 0 COMMENT '原始能量值', + MODIFY COLUMN `accepted_energy` BIGINT NOT NULL DEFAULT 0 COMMENT '实际计入火箭能量值', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL COMMENT '当天第几轮火箭', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级', + MODIFY COLUMN `status` VARCHAR(32) NOT NULL COMMENT '处理状态,IGNORED/SUCCESS/FAILED', + MODIFY COLUMN `raw_payload` MEDIUMTEXT COMMENT '原始消息内容', + MODIFY COLUMN `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '失败原因', + MODIFY COLUMN `event_time` DATETIME NOT NULL COMMENT '事件发生时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_launch` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭发射记录', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `launch_no` VARCHAR(64) NOT NULL COMMENT '发射编号', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `room_account` VARCHAR(64) DEFAULT NULL COMMENT '房间号', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL COMMENT '当天第几轮火箭', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级', + MODIFY COLUMN `final_energy` BIGINT NOT NULL COMMENT '发射时最终能量', + MODIFY COLUMN `top1_user_id` BIGINT DEFAULT NULL COMMENT '贡献第一用户ID', + MODIFY COLUMN `ignite_user_id` BIGINT DEFAULT NULL COMMENT '点火用户ID', + MODIFY COLUMN `in_room_snapshot_status` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '在房用户快照状态,PENDING/SUCCESS/FAILED', + MODIFY COLUMN `launch_time` DATETIME NOT NULL COMMENT '发射时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_reward_record` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭奖励记录', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `launch_id` BIGINT NOT NULL COMMENT '发射记录ID', + MODIFY COLUMN `launch_no` VARCHAR(64) NOT NULL COMMENT '发射编号', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `day_key` VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + MODIFY COLUMN `round_no` INT NOT NULL COMMENT '当天第几轮火箭', + MODIFY COLUMN `level` INT NOT NULL COMMENT '火箭等级', + MODIFY COLUMN `user_id` BIGINT NOT NULL COMMENT '获奖用户ID', + MODIFY COLUMN `reward_scene` VARCHAR(32) NOT NULL COMMENT '奖励场景,TOP1/IGNITE/IN_ROOM', + MODIFY COLUMN `reward_config_id` BIGINT NOT NULL COMMENT '奖励配置ID', + MODIFY COLUMN `reward_type` VARCHAR(32) NOT NULL COMMENT '奖励类型,GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE/NONE', + MODIFY COLUMN `reward_item_id` BIGINT DEFAULT NULL COMMENT '奖励资源ID', + MODIFY COLUMN `reward_name` VARCHAR(128) NOT NULL COMMENT '奖励名称', + MODIFY COLUMN `reward_cover` VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL', + MODIFY COLUMN `reward_amount` BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', + MODIFY COLUMN `expire_days` INT DEFAULT NULL COMMENT '时效类奖励有效天数', + MODIFY COLUMN `grant_event_id` VARCHAR(128) NOT NULL COMMENT '发奖幂等事件ID', + MODIFY COLUMN `grant_status` VARCHAR(32) NOT NULL COMMENT '发奖状态,PENDING/SUCCESS/FAILED', + MODIFY COLUMN `popup_status` VARCHAR(32) NOT NULL DEFAULT 'UNREAD' COMMENT '弹窗状态,UNREAD/READ', + MODIFY COLUMN `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '发奖失败原因', + MODIFY COLUMN `grant_time` DATETIME DEFAULT NULL COMMENT '发奖完成时间', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE `voice_room_rocket_audience_snapshot` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT = '语音房火箭在房用户快照', + MODIFY COLUMN `id` BIGINT NOT NULL COMMENT '主键ID', + MODIFY COLUMN `launch_id` BIGINT NOT NULL COMMENT '发射记录ID', + MODIFY COLUMN `sys_origin` VARCHAR(32) NOT NULL COMMENT '系统来源', + MODIFY COLUMN `room_id` BIGINT NOT NULL COMMENT '房间ID', + MODIFY COLUMN `user_id` BIGINT NOT NULL COMMENT '在房用户ID', + MODIFY COLUMN `last_seen_time` DATETIME NOT NULL COMMENT '最近在线时间', + MODIFY COLUMN `selected` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否被抽中发放在房奖励', + MODIFY COLUMN `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; diff --git a/migrations/040_agora_error_log.sql b/migrations/040_agora_error_log.sql new file mode 100644 index 0000000..493a2c1 --- /dev/null +++ b/migrations/040_agora_error_log.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS `agora_error_log` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `sys_origin` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '系统来源', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '上报用户ID', + `room_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '房间ID', + `agora_uid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Agora UID', + `error_code_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ErrorCodeType', + `connection_changed_reason_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ConnectionChangedReasonType', + `banned_by_server` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 BannedByServer/kicking-rule', + `is_timeout` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超时', + `token_request_success` TINYINT(1) DEFAULT NULL COMMENT 'token 请求是否成功', + `network_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '网络类型', + `req_client` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '请求客户端', + `req_app_intel` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'App版本信息请求头', + `client_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'User-Agent', + `raw_payload` MEDIUMTEXT COMMENT '原始上报内容', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_agora_error_sys_time` (`sys_origin`, `create_time`), + KEY `idx_agora_error_user_time` (`user_id`, `create_time`), + KEY `idx_agora_error_room_time` (`room_id`, `create_time`), + KEY `idx_agora_error_code_time` (`error_code_type`, `create_time`), + KEY `idx_agora_error_reason` (`connection_changed_reason_type`), + KEY `idx_agora_error_banned_time` (`banned_by_server`, `create_time`), + KEY `idx_agora_error_network` (`network_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agora错误日志';