Merge branch 'test'

This commit is contained in:
hy001 2026-05-15 19:42:30 +08:00
commit 39744caf2e
28 changed files with 2256 additions and 183 deletions

View File

@ -15,6 +15,7 @@ import (
"chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
@ -30,6 +31,7 @@ import (
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
@ -47,6 +49,7 @@ func main() {
} }
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis) 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) 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) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &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) rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways) taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
userBadgeService := userbadge.NewService(app.Repository.DB)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways) wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways) smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
@ -100,6 +104,7 @@ func main() {
) )
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService, AppPopup: appPopupService,
ErrorLog: errorLogService,
Invite: inviteService, Invite: inviteService,
Baishun: baishunService, Baishun: baishunService,
BinanceRecharge: binanceRechargeService, BinanceRecharge: binanceRechargeService,
@ -117,6 +122,7 @@ func main() {
SignInReward: signInRewardService, SignInReward: signInRewardService,
SmashEgg: smashEggService, SmashEgg: smashEggService,
TaskCenter: taskCenterService, TaskCenter: taskCenterService,
UserBadge: userBadgeService,
VIP: vipService, VIP: vipService,
Wheel: wheelService, Wheel: wheelService,
VoiceRoomRedPacket: voiceRoomRedPacketService, VoiceRoomRedPacket: voiceRoomRedPacketService,

View File

@ -0,0 +1,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" }

View File

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

View File

@ -28,6 +28,7 @@ type VoiceRoomRocketLevelConfig struct {
Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"` Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"`
NeedEnergy int64 `gorm:"column:need_energy"` NeedEnergy int64 `gorm:"column:need_energy"`
ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"` ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"`
PreviewRocketURL string `gorm:"column:preview_rocket_url;size:512"`
RocketIconURL string `gorm:"column:rocket_icon_url;size:512"` RocketIconURL string `gorm:"column:rocket_icon_url;size:512"`
RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"` RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"`
ProgressBarURL string `gorm:"column:progress_bar_url;size:512"` ProgressBarURL string `gorm:"column:progress_bar_url;size:512"`
@ -51,6 +52,7 @@ type VoiceRoomRocketRewardConfig struct {
RewardCover string `gorm:"column:reward_cover;size:512"` RewardCover string `gorm:"column:reward_cover;size:512"`
RewardAmount int64 `gorm:"column:reward_amount"` RewardAmount int64 `gorm:"column:reward_amount"`
ExpireDays *int `gorm:"column:expire_days"` ExpireDays *int `gorm:"column:expire_days"`
RewardCopies int `gorm:"column:reward_copies;default:1"`
Weight int `gorm:"column:weight"` Weight int `gorm:"column:weight"`
Sort int `gorm:"column:sort"` Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"` Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"`

View File

@ -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)
})
}

View File

@ -9,6 +9,7 @@ import (
"chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
@ -24,6 +25,7 @@ import (
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg" "chatapp3-golang/internal/service/smashegg"
"chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/taskcenter"
"chatapp3-golang/internal/service/userbadge"
"chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/vip"
"chatapp3-golang/internal/service/voiceroomredpacket" "chatapp3-golang/internal/service/voiceroomredpacket"
"chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/voiceroomrocket"
@ -36,6 +38,7 @@ import (
// Services 聚合所有业务模块服务,供路由层统一装配。 // Services 聚合所有业务模块服务,供路由层统一装配。
type Services struct { type Services struct {
AppPopup *apppopup.Service AppPopup *apppopup.Service
ErrorLog *errorlog.Service
Invite *invite.InviteService Invite *invite.InviteService
Baishun *baishun.BaishunService Baishun *baishun.BaishunService
BinanceRecharge *binancerecharge.Service BinanceRecharge *binancerecharge.Service
@ -53,6 +56,7 @@ type Services struct {
SignInReward *signinreward.SignInRewardService SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service TaskCenter *taskcenter.Service
UserBadge *userbadge.Service
VIP *vip.Service VIP *vip.Service
Wheel *wheel.Service Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service VoiceRoomRedPacket *voiceroomredpacket.Service
@ -72,6 +76,7 @@ func NewRouter(
registerHealthRoutes(engine, cfg, repository) registerHealthRoutes(engine, cfg, repository)
registerAppPopupRoutes(engine, javaClient, services.AppPopup) registerAppPopupRoutes(engine, javaClient, services.AppPopup)
registerAgoraErrorLogRoutes(engine, javaClient, services.ErrorLog)
registerInviteRoutes(engine, javaClient, services.Invite) registerInviteRoutes(engine, javaClient, services.Invite)
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
@ -79,6 +84,7 @@ func NewRouter(
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerUserBadgeRoutes(engine, javaClient, services.UserBadge)
registerVipRoutes(engine, javaClient, services.VIP) registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, javaClient, services.Wheel) registerWheelRoutes(engine, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg) registerSmashEggRoutes(engine, javaClient, services.SmashEgg)

View File

@ -0,0 +1,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)
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 ""
}

View File

@ -163,6 +163,7 @@ func (s *Service) SaveLevelConfigs(ctx context.Context, req SaveLevelConfigsRequ
} }
row.NeedEnergy = item.NeedEnergy row.NeedEnergy = item.NeedEnergy
row.ShakeThresholdPercent = threshold row.ShakeThresholdPercent = threshold
row.PreviewRocketURL = strings.TrimSpace(item.PreviewRocketURL)
row.RocketIconURL = strings.TrimSpace(item.RocketIconURL) row.RocketIconURL = strings.TrimSpace(item.RocketIconURL)
row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL) row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL)
row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL) row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL)
@ -264,6 +265,12 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe
} else if amount <= 0 { } else if amount <= 0 {
amount = 1 amount = 1
} }
rewardCopies := item.RewardCopies
if scene != rewardSceneInRoom {
rewardCopies = 1
} else if rewardCopies <= 0 {
rewardCopies = 1
}
var row model.VoiceRoomRocketRewardConfig var row model.VoiceRoomRocketRewardConfig
if item.ID > 0 { if item.ID > 0 {
if err := tx.Where("id = ? AND sys_origin = ?", item.ID, sysOrigin).First(&row).Error; err != nil { 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.RewardCover = strings.TrimSpace(item.RewardCover)
row.RewardAmount = amount row.RewardAmount = amount
row.ExpireDays = item.ExpireDays row.ExpireDays = item.ExpireDays
row.RewardCopies = rewardCopies
row.Weight = item.Weight row.Weight = item.Weight
row.Sort = item.Sort row.Sort = item.Sort
row.Enabled = item.Enabled row.Enabled = item.Enabled

View File

@ -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)
}
}

View File

@ -176,7 +176,7 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10) seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5) 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) roomID := int64(9001)
for _, userID := range []int64{1001, 1002, 1003, 1004} { 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) { func TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) {
gateway := newFakeRocketGateway() gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway) 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) { func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
gateway := newFakeRocketGateway() gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway) service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close() defer mr.Close()
seedRocketConfig(t, db) 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{} fakeIM := &fakeRocketIM{}
service.SetIMGateway(fakeIM) service.SetIMGateway(fakeIM)
@ -360,6 +516,23 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" { if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" {
t.Fatalf("im messages = %+v", fakeIM.messages) 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() count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result()
if err != nil { if err != nil {
t.Fatalf("xlen broadcast: %v", err) t.Fatalf("xlen broadcast: %v", err)
@ -457,10 +630,11 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) {
Rewards: []RewardConfigPayload{ Rewards: []RewardConfigPayload{
{ {
Level: 1, Level: 1,
RewardScene: rewardSceneTop1, RewardScene: rewardSceneInRoom,
RewardType: rewardTypeGold, RewardType: rewardTypeGold,
RewardName: "new gold", RewardName: "new gold",
RewardAmount: 99, RewardAmount: 99,
RewardCopies: 10,
Enabled: true, Enabled: true,
Sort: 1, Sort: 1,
}, },
@ -469,8 +643,8 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveRewardConfigs(replace) error = %v", err) t.Fatalf("SaveRewardConfigs(replace) error = %v", err)
} }
if len(saved) != 1 || saved[0].RewardAmount != 99 { if len(saved) != 1 || saved[0].RewardAmount != 99 || saved[0].RewardCopies != 10 {
t.Fatalf("saved rewards = %+v, want one amount 99", saved) t.Fatalf("saved rewards = %+v, want one amount 99 and 10 copies", saved)
} }
var level1Count int64 var level1Count int64
@ -570,7 +744,13 @@ func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service
type fakeRocketGateway struct { type fakeRocketGateway struct {
goldEvents map[string]integration.GoldReceiptCommand goldEvents map[string]integration.GoldReceiptCommand
props []integration.GivePropsBackpackRequest props []integration.GivePropsBackpackRequest
badges []int64 badges []fakeBadgeGrant
}
type fakeBadgeGrant struct {
userID int64
badgeID int64
days int
} }
type fakeRocketIM struct { 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 { 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 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) { 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() t.Helper()
now := time.Now() now := time.Now()
row := model.VoiceRoomRocketRewardConfig{ row := model.VoiceRoomRocketRewardConfig{
@ -721,6 +905,7 @@ func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, reward
RewardType: rewardType, RewardType: rewardType,
RewardName: scene + " reward", RewardName: scene + " reward",
RewardAmount: amount, RewardAmount: amount,
RewardCopies: copies,
Enabled: true, Enabled: true,
CreateTime: now, CreateTime: now,
UpdateTime: now, UpdateTime: now,

View File

@ -88,7 +88,11 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string
if err != nil { if err != nil {
return err 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 { if err := s.saveAudienceSnapshot(ctx, tx, launch, audience, selected, now); err != nil {
return err return err
} }
@ -100,12 +104,9 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string
return nil return nil
} }
rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) pickedRewards := s.pickInRoomRewardsForUsers(launch, rewards, selected)
if err != nil {
return err
}
for _, userID := range 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) record, err := s.createRewardRecord(ctx, tx, launch, reward, userID, now)
if err != nil { if err != nil {
return err return err
@ -201,11 +202,8 @@ func (s *Service) loadInRoomRewardConfigs(ctx context.Context, sysOrigin string,
return result, nil return result, nil
} }
func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser) []int64 { func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser, rewards []model.VoiceRoomRocketRewardConfig) []int64 {
limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit limit := s.inRoomRewardSelectionLimit(rewards)
if limit <= 0 {
limit = defaultInRoomRewardUserLimit
}
if len(audience) == 0 { if len(audience) == 0 {
return nil return nil
} }
@ -228,37 +226,80 @@ func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, au
return selected return selected
} }
func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { func (s *Service) inRoomRewardSelectionLimit(rewards []model.VoiceRoomRocketRewardConfig) int {
if len(rewards) == 0 { totalCopies := 0
return nil 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)) if totalCopies > 0 {
totalWeight := 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 { for _, reward := range rewards {
weight := reward.Weight weight := reward.Weight
if weight <= 0 { if weight <= 0 {
weight = 1 weight = 1
} }
reward.Weight = weight reward.Weight = weight
weighted = append(weighted, reward) weighted = append(weighted, rewardCandidate{
totalWeight += weight reward: reward,
remaining: positiveOrDefault(reward.RewardCopies, 1),
})
} }
if totalWeight <= 0 {
return nil for _, userID := range userIDs {
} totalWeight := 0
rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) for _, candidate := range weighted {
point := rng.Intn(totalWeight) + 1 if candidate.remaining > 0 {
acc := 0 totalWeight += candidate.reward.Weight
for _, reward := range weighted { }
acc += reward.Weight }
if point <= acc { if totalWeight <= 0 {
if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) { continue
return nil }
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 { func seedFromStrings(parts ...string) int64 {

View File

@ -140,6 +140,7 @@ func levelPayload(row model.VoiceRoomRocketLevelConfig) LevelConfigPayload {
Level: row.Level, Level: row.Level,
NeedEnergy: row.NeedEnergy, NeedEnergy: row.NeedEnergy,
ShakeThresholdPercent: row.ShakeThresholdPercent, ShakeThresholdPercent: row.ShakeThresholdPercent,
PreviewRocketURL: row.PreviewRocketURL,
RocketIconURL: row.RocketIconURL, RocketIconURL: row.RocketIconURL,
RocketAnimationURL: row.RocketAnimationURL, RocketAnimationURL: row.RocketAnimationURL,
ProgressBarURL: row.ProgressBarURL, ProgressBarURL: row.ProgressBarURL,
@ -159,6 +160,7 @@ func rewardPayload(row model.VoiceRoomRocketRewardConfig) RewardConfigPayload {
RewardCover: row.RewardCover, RewardCover: row.RewardCover,
RewardAmount: row.RewardAmount, RewardAmount: row.RewardAmount,
ExpireDays: row.ExpireDays, ExpireDays: row.ExpireDays,
RewardCopies: positiveOrDefault(row.RewardCopies, 1),
Weight: row.Weight, Weight: row.Weight,
Sort: row.Sort, Sort: row.Sort,
Enabled: row.Enabled, Enabled: row.Enabled,

View File

@ -120,18 +120,29 @@ func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocket
if launch.IgniteUserID != nil { if launch.IgniteUserID != nil {
triggerUserID = strconv.FormatInt(*launch.IgniteUserID, 10) 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{ body := map[string]any{
"type": imTypeLaunchBroadcast, "type": imTypeLaunchBroadcast,
"data": map[string]any{ "data": map[string]any{
"roomId": strconv.FormatInt(launch.RoomID, 10), "roomId": strconv.FormatInt(launch.RoomID, 10),
"roomAccount": launch.RoomAccount, "roomAccount": launch.RoomAccount,
"roomName": roomName, "roomName": roomName,
"roundNo": launch.RoundNo, "roundNo": launch.RoundNo,
"level": launch.Level, "level": launch.Level,
"durationSeconds": durationSeconds, "durationSeconds": durationSeconds,
"triggerUser": triggerUser, "previewRocketUrl": previewRocketURL,
"triggerUserId": triggerUserID, "rocketIconUrl": rocketIconURL,
"launchNo": launch.LaunchNo, "rocketAnimationUrl": rocketAnimationURL,
"triggerUser": triggerUser,
"triggerUserId": triggerUserID,
"launchNo": launch.LaunchNo,
}, },
} }
if s.repo.Redis != nil { if s.repo.Redis != nil {

View File

@ -2,6 +2,7 @@ package voiceroomrocket
import ( import (
"context" "context"
"crypto/sha1"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -187,7 +188,8 @@ func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocket
if s.wallet == nil { if s.wallet == nil {
return NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") 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 { if err != nil {
return err return err
} }
@ -196,13 +198,13 @@ func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocket
ReceiptType: "INCOME", ReceiptType: "INCOME",
UserID: row.UserID, UserID: row.UserID,
SysOrigin: row.SysOrigin, SysOrigin: row.SysOrigin,
EventID: row.GrantEventID, EventID: walletEventID,
Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level), Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level),
Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount), Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount),
CloseDelayAsset: true, CloseDelayAsset: true,
OpUserType: "BACK", OpUserType: "BACK",
CustomizeOrigin: "VOICE_ROOM_ROCKET", Origin: walletOriginRocketReward,
CustomizeOriginDesc: "Voice Room Rocket Reward", CustomizeOriginDesc: walletOriginRocketRewardDesc,
}); err != nil { }); err != nil {
return err 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 { 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)
} }

View File

@ -58,6 +58,9 @@ func (s *Service) GetStatus(ctx context.Context, user AuthUser, roomID int64) (*
DisplayPercent: displayPercent, DisplayPercent: displayPercent,
LaunchCountdownSeconds: countdownSeconds, LaunchCountdownSeconds: countdownSeconds,
Shake: shake, Shake: shake,
PreviewRocketURL: currentLevelConfig.PreviewRocketURL,
RocketIconURL: currentLevelConfig.RocketIconURL,
RocketAnimationURL: currentLevelConfig.RocketAnimationURL,
Levels: buildLevelPayloads(levels), Levels: buildLevelPayloads(levels),
RewardPreview: rewardPreviewPayload(rewards), RewardPreview: rewardPreviewPayload(rewards),
RocketKings: kings, RocketKings: kings,

View File

@ -77,6 +77,9 @@ const (
propsTypeGift = "GIFT" propsTypeGift = "GIFT"
propsTypeBadge = "BADGE" propsTypeBadge = "BADGE"
walletOriginRocketReward = "ROCKET_REWARD"
walletOriginRocketRewardDesc = "Rocket reward"
imTypeStatusUpdate = "VOICE_ROOM_ROCKET_STATUS_UPDATE" imTypeStatusUpdate = "VOICE_ROOM_ROCKET_STATUS_UPDATE"
imTypeLaunchBroadcast = "VOICE_ROOM_ROCKET_LAUNCH_BROADCAST" imTypeLaunchBroadcast = "VOICE_ROOM_ROCKET_LAUNCH_BROADCAST"
imTypeRewardPopup = "VOICE_ROOM_ROCKET_REWARD_POPUP" imTypeRewardPopup = "VOICE_ROOM_ROCKET_REWARD_POPUP"
@ -315,6 +318,7 @@ type LevelConfigPayload struct {
Level int `json:"level"` Level int `json:"level"`
NeedEnergy int64 `json:"needEnergy"` NeedEnergy int64 `json:"needEnergy"`
ShakeThresholdPercent string `json:"shakeThresholdPercent"` ShakeThresholdPercent string `json:"shakeThresholdPercent"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
RocketIconURL string `json:"rocketIconUrl,omitempty"` RocketIconURL string `json:"rocketIconUrl,omitempty"`
RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"` RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"`
ProgressBarURL string `json:"progressBarUrl,omitempty"` ProgressBarURL string `json:"progressBarUrl,omitempty"`
@ -332,6 +336,7 @@ type RewardConfigPayload struct {
RewardCover string `json:"rewardCover,omitempty"` RewardCover string `json:"rewardCover,omitempty"`
RewardAmount int64 `json:"rewardAmount"` RewardAmount int64 `json:"rewardAmount"`
ExpireDays *int `json:"expireDays,omitempty"` ExpireDays *int `json:"expireDays,omitempty"`
RewardCopies int `json:"rewardCopies"`
Weight int `json:"weight"` Weight int `json:"weight"`
Sort int `json:"sort"` Sort int `json:"sort"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@ -398,6 +403,9 @@ type StatusResponse struct {
DisplayPercent int `json:"displayPercent"` DisplayPercent int `json:"displayPercent"`
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
Shake bool `json:"shake"` Shake bool `json:"shake"`
PreviewRocketURL string `json:"previewRocketUrl,omitempty"`
RocketIconURL string `json:"rocketIconUrl,omitempty"`
RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"`
Levels []LevelConfigPayload `json:"levels"` Levels []LevelConfigPayload `json:"levels"`
RewardPreview RewardPreviewResponse `json:"rewardPreview"` RewardPreview RewardPreviewResponse `json:"rewardPreview"`
RocketKings []KingRecord `json:"rocketKings"` RocketKings []KingRecord `json:"rocketKings"`

View File

@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS voice_room_rocket_config ( 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 '系统来源', sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' 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 '在房间奖励延迟窗口秒数', in_room_reward_delay_seconds INT NOT NULL DEFAULT 10 COMMENT '在房间奖励延迟窗口秒数',
energy_rule_json JSON DEFAULT NULL COMMENT '礼物折算规则', energy_rule_json JSON DEFAULT NULL COMMENT '礼物折算规则',
rule_text JSON DEFAULT NULL COMMENT '规则说明多语言文案', rule_text JSON DEFAULT NULL COMMENT '规则说明多语言文案',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_voice_room_rocket_config (sys_origin) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_level_config (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
level INT NOT NULL COMMENT 'Lv.1-Lv.6', level INT NOT NULL COMMENT '火箭等级,Lv.1-Lv.6',
need_energy BIGINT NOT NULL COMMENT '本级发射所需能量', need_energy BIGINT NOT NULL COMMENT '本级发射所需能量',
shake_threshold_percent DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '抖动阈值百分比', shake_threshold_percent DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '抖动阈值百分比',
rocket_icon_url VARCHAR(512) DEFAULT NULL, preview_rocket_url VARCHAR(512) DEFAULT NULL COMMENT '预览火箭资源URL',
rocket_animation_url VARCHAR(512) DEFAULT NULL, rocket_icon_url VARCHAR(512) DEFAULT NULL COMMENT '火箭图标资源URL',
progress_bar_url VARCHAR(512) DEFAULT NULL, rocket_animation_url VARCHAR(512) DEFAULT NULL COMMENT '火箭发射动画资源URL',
enabled TINYINT(1) NOT NULL DEFAULT 1, progress_bar_url VARCHAR(512) DEFAULT NULL COMMENT '进度条资源URL历史字段',
sort INT NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, sort INT NOT NULL DEFAULT 0 COMMENT '排序值,升序',
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 (sys_origin, level) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_config (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
level INT NOT NULL COMMENT '适用等级', level INT NOT NULL COMMENT '适用等级',
reward_scene VARCHAR(32) NOT NULL COMMENT 'TOP1/IGNITE/IN_ROOM', 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_item_id BIGINT DEFAULT NULL COMMENT '道具、礼物、权益等资源ID',
reward_name VARCHAR(128) NOT NULL, reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称',
reward_cover VARCHAR(512) DEFAULT NULL, reward_cover VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL',
reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数',
expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数', expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数',
weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机权重0 表示固定发放', reward_copies INT NOT NULL DEFAULT 1 COMMENT 'IN_ROOM 本奖励每次发射可发放份数',
sort INT NOT NULL DEFAULT 0, weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机抽取权重小于等于0按1处理',
enabled TINYINT(1) NOT NULL DEFAULT 1, sort INT NOT NULL DEFAULT 0 COMMENT '排序值,升序',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
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 '更新时间',
KEY idx_voice_room_rocket_reward_scene (sys_origin, level, reward_scene, enabled) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_status (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL DEFAULT 1, round_no INT NOT NULL DEFAULT 1 COMMENT '当天第几轮火箭',
current_level INT NOT NULL DEFAULT 1, current_level INT NOT NULL DEFAULT 1 COMMENT '当前等级',
current_energy BIGINT NOT NULL DEFAULT 0, current_energy BIGINT NOT NULL DEFAULT 0 COMMENT '当前等级已累计能量',
need_energy BIGINT NOT NULL, need_energy BIGINT NOT NULL COMMENT '当前等级所需能量',
status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHING', status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHING',
level_start_time DATETIME NOT NULL, level_start_time DATETIME NOT NULL COMMENT '当前等级开始时间',
last_event_time DATETIME DEFAULT NULL, last_event_time DATETIME DEFAULT NULL COMMENT '最近一次有效送礼事件时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_level_round (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
day_key VARCHAR(16) NOT NULL, day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL, round_no INT NOT NULL COMMENT '当天第几轮火箭',
level INT NOT NULL, level INT NOT NULL COMMENT '火箭等级',
need_energy BIGINT NOT NULL, need_energy BIGINT NOT NULL COMMENT '本级所需能量',
final_energy BIGINT NOT NULL DEFAULT 0, final_energy BIGINT NOT NULL DEFAULT 0 COMMENT '本级最终累计能量',
status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHED', status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHED',
top1_user_id BIGINT DEFAULT NULL, top1_user_id BIGINT DEFAULT NULL COMMENT '本级贡献第一用户ID',
ignite_user_id BIGINT DEFAULT NULL, ignite_user_id BIGINT DEFAULT NULL COMMENT '点火用户ID',
reach_time DATETIME DEFAULT NULL COMMENT '能量达标时间', reach_time DATETIME DEFAULT NULL COMMENT '能量达标时间',
launch_time DATETIME DEFAULT NULL COMMENT '发射时间', launch_time DATETIME DEFAULT NULL COMMENT '发射时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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), 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) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_contribution (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
day_key VARCHAR(16) NOT NULL, day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL, round_no INT NOT NULL COMMENT '当天第几轮火箭',
level INT NOT NULL, level INT NOT NULL COMMENT '火箭等级',
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT '贡献用户ID',
score_energy BIGINT NOT NULL DEFAULT 0, score_energy BIGINT NOT NULL DEFAULT 0 COMMENT '计入榜单的能量值',
gift_count INT NOT NULL DEFAULT 0, gift_count INT NOT NULL DEFAULT 0 COMMENT '计入火箭的送礼次数',
first_contribute_time DATETIME NOT NULL, first_contribute_time DATETIME NOT NULL COMMENT '首次贡献时间',
last_contribute_time DATETIME NOT NULL, last_contribute_time DATETIME NOT NULL COMMENT '最近贡献时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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), 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) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_gift_event_log (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
track_id VARCHAR(128) NOT NULL, track_id VARCHAR(128) NOT NULL COMMENT '送礼事件唯一追踪ID',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
send_user_id BIGINT NOT NULL, send_user_id BIGINT NOT NULL COMMENT '送礼用户ID',
gift_id BIGINT DEFAULT NULL, gift_id BIGINT DEFAULT NULL COMMENT '礼物ID',
gift_type VARCHAR(32) DEFAULT NULL, gift_type VARCHAR(32) DEFAULT NULL COMMENT '礼物类型',
gift_tab VARCHAR(64) DEFAULT NULL, gift_tab VARCHAR(64) DEFAULT NULL COMMENT '礼物分类',
raw_energy BIGINT NOT NULL DEFAULT 0, raw_energy BIGINT NOT NULL DEFAULT 0 COMMENT '原始能量值',
accepted_energy BIGINT NOT NULL DEFAULT 0, accepted_energy BIGINT NOT NULL DEFAULT 0 COMMENT '实际计入火箭能量值',
day_key VARCHAR(16) NOT NULL, day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL, round_no INT NOT NULL COMMENT '当天第几轮火箭',
level INT NOT NULL, level INT NOT NULL COMMENT '火箭等级',
status VARCHAR(32) NOT NULL COMMENT 'IGNORED/SUCCESS/FAILED', status VARCHAR(32) NOT NULL COMMENT 'IGNORED/SUCCESS/FAILED',
raw_payload MEDIUMTEXT, raw_payload MEDIUMTEXT COMMENT '原始消息内容',
error_message VARCHAR(1024) DEFAULT NULL, error_message VARCHAR(1024) DEFAULT NULL COMMENT '失败原因',
event_time DATETIME NOT NULL, event_time DATETIME NOT NULL COMMENT '事件发生时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_voice_room_rocket_track (track_id), 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) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_launch (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
launch_no VARCHAR(64) NOT NULL, launch_no VARCHAR(64) NOT NULL COMMENT '发射编号',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
room_account VARCHAR(64) DEFAULT NULL, room_account VARCHAR(64) DEFAULT NULL COMMENT '房间号',
day_key VARCHAR(16) NOT NULL, day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL, round_no INT NOT NULL COMMENT '当天第几轮火箭',
level INT NOT NULL, level INT NOT NULL COMMENT '火箭等级',
final_energy BIGINT NOT NULL, final_energy BIGINT NOT NULL COMMENT '发射时最终能量',
top1_user_id BIGINT DEFAULT NULL, top1_user_id BIGINT DEFAULT NULL COMMENT '贡献第一用户ID',
ignite_user_id BIGINT DEFAULT NULL, ignite_user_id BIGINT DEFAULT NULL COMMENT '点火用户ID',
in_room_snapshot_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', in_room_snapshot_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '在房用户快照状态PENDING/SUCCESS/FAILED',
launch_time DATETIME NOT NULL, launch_time DATETIME NOT NULL COMMENT '发射时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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_no (launch_no),
UNIQUE KEY uk_voice_room_rocket_launch_level (sys_origin, room_id, day_key, round_no, level), 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) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_record (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
launch_id BIGINT NOT NULL, launch_id BIGINT NOT NULL COMMENT '发射记录ID',
launch_no VARCHAR(64) NOT NULL, launch_no VARCHAR(64) NOT NULL COMMENT '发射编号',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
day_key VARCHAR(16) NOT NULL, day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd',
round_no INT NOT NULL, round_no INT NOT NULL COMMENT '当天第几轮火箭',
level INT NOT NULL, level INT NOT NULL COMMENT '火箭等级',
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT '获奖用户ID',
reward_scene VARCHAR(32) NOT NULL, reward_scene VARCHAR(32) NOT NULL COMMENT '奖励场景TOP1/IGNITE/IN_ROOM',
reward_config_id BIGINT NOT NULL, reward_config_id BIGINT NOT NULL COMMENT '奖励配置ID',
reward_type VARCHAR(32) NOT NULL, reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE/NONE',
reward_item_id BIGINT DEFAULT NULL, reward_item_id BIGINT DEFAULT NULL COMMENT '奖励资源ID',
reward_name VARCHAR(128) NOT NULL, reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称',
reward_cover VARCHAR(512) DEFAULT NULL, reward_cover VARCHAR(512) DEFAULT NULL COMMENT '奖励封面URL',
reward_amount BIGINT NOT NULL DEFAULT 1, reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数',
expire_days INT DEFAULT NULL, expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数',
grant_event_id VARCHAR(128) NOT NULL, grant_event_id VARCHAR(128) NOT NULL COMMENT '发奖幂等事件ID',
grant_status VARCHAR(32) NOT NULL COMMENT 'PENDING/SUCCESS/FAILED', grant_status VARCHAR(32) NOT NULL COMMENT 'PENDING/SUCCESS/FAILED',
popup_status VARCHAR(32) NOT NULL DEFAULT 'UNREAD' COMMENT 'UNREAD/READ', popup_status VARCHAR(32) NOT NULL DEFAULT 'UNREAD' COMMENT 'UNREAD/READ',
error_message VARCHAR(1024) DEFAULT NULL, error_message VARCHAR(1024) DEFAULT NULL COMMENT '发奖失败原因',
grant_time DATETIME DEFAULT NULL, grant_time DATETIME DEFAULT NULL COMMENT '发奖完成时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_voice_room_rocket_reward_event (grant_event_id), 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_user (sys_origin, user_id, create_time),
KEY idx_voice_room_rocket_reward_launch (launch_id, user_id), KEY idx_voice_room_rocket_reward_launch (launch_id, user_id),
KEY idx_voice_room_rocket_reward_retry (grant_status, update_time) 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 ( CREATE TABLE IF NOT EXISTS voice_room_rocket_audience_snapshot (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY COMMENT '主键ID',
launch_id BIGINT NOT NULL, launch_id BIGINT NOT NULL COMMENT '发射记录ID',
sys_origin VARCHAR(32) NOT NULL, sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源',
room_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT '房间ID',
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT '在房用户ID',
last_seen_time DATETIME NOT NULL, last_seen_time DATETIME NOT NULL COMMENT '最近在线时间',
selected TINYINT(1) NOT NULL DEFAULT 0, selected TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否被抽中发放在房奖励',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_voice_room_rocket_audience (launch_id, user_id), UNIQUE KEY uk_voice_room_rocket_audience (launch_id, user_id),
KEY idx_voice_room_rocket_audience_launch (launch_id, selected) 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='语音房火箭在房间用户快照';

View File

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

View File

@ -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;

View File

@ -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 '创建时间';

View File

@ -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错误日志';