From bbf50367e24c602a127acb280a987642d7965b07 Mon Sep 17 00:00:00 2001 From: hy001 Date: Fri, 15 May 2026 19:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A3=B0=E7=BD=91=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/main.go | 3 + internal/model/agora_error_log_models.go | 28 ++ internal/router/agora_error_log_routes.go | 81 ++++++ internal/router/router.go | 3 + internal/router/task_center_routes.go | 2 + internal/router/user_badge_routes.go | 12 + internal/router/user_badge_routes_test.go | 78 ++++++ internal/service/errorlog/agora.go | 173 ++++++++++++ internal/service/errorlog/agora_test.go | 84 ++++++ internal/service/errorlog/types.go | 263 ++++++++++++++++++ internal/service/taskcenter/admin.go | 124 ++++++++- .../service/taskcenter/taskcenter_test.go | 101 +++++++ internal/service/taskcenter/types.go | 14 + internal/service/userbadge/current.go | 26 +- internal/service/userbadge/current_test.go | 51 ++++ internal/service/userbadge/types.go | 13 + .../service/voiceroomrocket/config_test.go | 41 +++ .../service/voiceroomrocket/event_test.go | 105 ++++++- internal/service/voiceroomrocket/notify.go | 29 +- internal/service/voiceroomrocket/status.go | 3 + internal/service/voiceroomrocket/types.go | 3 + migrations/040_agora_error_log.sql | 28 ++ ...task_center_claim_record_admin_indexes.sql | 46 +++ 23 files changed, 1293 insertions(+), 18 deletions(-) create mode 100644 internal/model/agora_error_log_models.go create mode 100644 internal/router/agora_error_log_routes.go create mode 100644 internal/service/errorlog/agora.go create mode 100644 internal/service/errorlog/agora_test.go create mode 100644 internal/service/errorlog/types.go create mode 100644 migrations/040_agora_error_log.sql create mode 100644 migrations/041_task_center_claim_record_admin_indexes.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index 76b9d71..b3c1b4e 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" + "chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -48,6 +49,7 @@ func main() { } appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis) + errorLogService := errorlog.NewService(app.Repository.DB) inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) @@ -102,6 +104,7 @@ func main() { ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ AppPopup: appPopupService, + ErrorLog: errorLogService, Invite: inviteService, Baishun: baishunService, BinanceRecharge: binanceRechargeService, diff --git a/internal/model/agora_error_log_models.go b/internal/model/agora_error_log_models.go new file mode 100644 index 0000000..fe1b0e8 --- /dev/null +++ b/internal/model/agora_error_log_models.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// AgoraErrorLog stores client-reported Agora room connection errors. +type AgoraErrorLog struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键ID + SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统来源 + UserID int64 `gorm:"column:user_id"` // 上报用户ID + RoomID string `gorm:"column:room_id;size:64"` // 房间ID + AgoraUID string `gorm:"column:agora_uid;size:64"` // Agora UID + ErrorCodeType string `gorm:"column:error_code_type;size:128"` // Agora ErrorCodeType + ConnectionChangedReasonType string `gorm:"column:connection_changed_reason_type;size:128"` // Agora ConnectionChangedReasonType + BannedByServer bool `gorm:"column:banned_by_server"` // 是否 BannedByServer/kicking-rule + IsTimeout bool `gorm:"column:is_timeout"` // 是否超时 + TokenRequestSuccess *bool `gorm:"column:token_request_success"` // token 请求是否成功 + NetworkType string `gorm:"column:network_type;size:64"` // 网络类型 + ReqClient string `gorm:"column:req_client;size:32"` // 请求客户端 + ReqAppIntel string `gorm:"column:req_app_intel;size:255"` // App 版本信息请求头 + ClientIP string `gorm:"column:client_ip;size:64"` // 客户端IP + UserAgent string `gorm:"column:user_agent;size:255"` // User-Agent + RawPayload string `gorm:"column:raw_payload;type:mediumtext"` // 原始上报内容 + CreateTime time.Time `gorm:"column:create_time"` // 创建时间 + UpdateTime time.Time `gorm:"column:update_time"` // 更新时间 +} + +// TableName returns the Agora error log table name. +func (AgoraErrorLog) TableName() string { return "agora_error_log" } diff --git a/internal/router/agora_error_log_routes.go b/internal/router/agora_error_log_routes.go new file mode 100644 index 0000000..88920b4 --- /dev/null +++ b/internal/router/agora_error_log_routes.go @@ -0,0 +1,81 @@ +package router + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/errorlog" + + "github.com/gin-gonic/gin" +) + +// registerAgoraErrorLogRoutes registers client report and admin page APIs for Agora errors. +func registerAgoraErrorLogRoutes(engine *gin.Engine, javaClient authGateway, service *errorlog.Service) { + if service == nil { + return + } + + appGroup := engine.Group("/app/agora") + appGroup.Use(authMiddleware(javaClient)) + appGroup.POST("/error-log", func(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + if err != nil { + writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + var req errorlog.AgoraErrorReportRequest + if len(strings.TrimSpace(string(raw))) > 0 { + if err := json.Unmarshal(raw, &req); err != nil { + writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + } + resp, err := service.ReportAgoraError( + c.Request.Context(), + mustAuthUser(c), + req, + errorlog.AgoraErrorReportContext{ + ReqClient: c.GetHeader("Req-Client"), + ReqAppIntel: c.GetHeader("Req-App-Intel"), + ClientIP: resolveClientIP(c), + UserAgent: c.GetHeader("User-Agent"), + RawPayload: string(raw), + }, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/app-system/error-logs/agora") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/page", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + bannedOnly, _ := strconv.ParseBool(strings.TrimSpace(c.DefaultQuery("bannedOnly", "false"))) + resp, err := service.PageAgoraErrorLogs(c.Request.Context(), errorlog.AgoraErrorLogQuery{ + SysOrigin: c.Query("sysOrigin"), + UserID: parseInt64(c.Query("userId")), + RoomID: c.Query("roomId"), + AgoraUID: c.Query("agoraUid"), + ErrorCodeType: c.Query("errorCodeType"), + ReasonType: c.Query("reasonType"), + BannedOnly: bannedOnly, + NetworkType: c.Query("networkType"), + StartTime: c.Query("startTime"), + EndTime: c.Query("endTime"), + Cursor: cursor, + Limit: limit, + }) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 50a2806..0c6c0c7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -9,6 +9,7 @@ import ( "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/binancerecharge" + "chatapp3-golang/internal/service/errorlog" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -37,6 +38,7 @@ import ( // Services 聚合所有业务模块服务,供路由层统一装配。 type Services struct { AppPopup *apppopup.Service + ErrorLog *errorlog.Service Invite *invite.InviteService Baishun *baishun.BaishunService BinanceRecharge *binancerecharge.Service @@ -74,6 +76,7 @@ func NewRouter( registerHealthRoutes(engine, cfg, repository) registerAppPopupRoutes(engine, javaClient, services.AppPopup) + registerAgoraErrorLogRoutes(engine, javaClient, services.ErrorLog) registerInviteRoutes(engine, javaClient, services.Invite) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) diff --git a/internal/router/task_center_routes.go b/internal/router/task_center_routes.go index b5e7c3f..4b7605c 100644 --- a/internal/router/task_center_routes.go +++ b/internal/router/task_center_routes.go @@ -121,6 +121,8 @@ func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient taskcenter.ParseInt64(c.Query("userId")), c.Query("taskCode"), c.Query("status"), + c.Query("startTime"), + c.Query("endTime"), int(taskcenter.ParseInt64(c.Query("cursor"))), int(taskcenter.ParseInt64(c.Query("limit"))), ) diff --git a/internal/router/user_badge_routes.go b/internal/router/user_badge_routes.go index 4861bec..4a541ba 100644 --- a/internal/router/user_badge_routes.go +++ b/internal/router/user_badge_routes.go @@ -22,4 +22,16 @@ func registerUserBadgeRoutes(engine *gin.Engine, javaClient authGateway, service } writeOK(c, resp) }) + appGroup.GET("/other", func(c *gin.Context) { + targetUserID := userbadge.ParseUserID(c.Query("userId")) + if targetUserID <= 0 { + targetUserID = userbadge.ParseUserID(c.Query("targetUserId")) + } + resp, err := service.Other(c.Request.Context(), mustAuthUser(c), targetUserID) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) } diff --git a/internal/router/user_badge_routes_test.go b/internal/router/user_badge_routes_test.go index 4b48794..364462f 100644 --- a/internal/router/user_badge_routes_test.go +++ b/internal/router/user_badge_routes_test.go @@ -103,6 +103,84 @@ func TestUserBadgeCurrentRouteRequiresAuth(t *testing.T) { } } +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{}) diff --git a/internal/service/errorlog/agora.go b/internal/service/errorlog/agora.go new file mode 100644 index 0000000..914421c --- /dev/null +++ b/internal/service/errorlog/agora.go @@ -0,0 +1,173 @@ +package errorlog + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// ReportAgoraError persists one client-reported Agora error log. +func (s *Service) ReportAgoraError(ctx context.Context, user AuthUser, req AgoraErrorReportRequest, meta AgoraErrorReportContext) (*AgoraErrorReportResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable") + } + roomID := req.RoomID.String() + if roomID == "" { + return nil, NewAppError(http.StatusBadRequest, "room_id_required", "roomId is required") + } + reasonType := firstNonBlank(req.ConnectionChangedReasonType, req.ReasonType, req.Reason) + bannedByServer := isBannedByServer(reasonType) + + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + row := model.AgoraErrorLog{ + ID: id, + SysOrigin: normalizeSysOrigin(user.SysOrigin), + UserID: user.UserID, + RoomID: trimMax(roomID, 64), + AgoraUID: trimMax(req.AgoraUID.String(), 64), + ErrorCodeType: trimMax(req.ErrorCodeType, 128), + ConnectionChangedReasonType: trimMax(reasonType, 128), + BannedByServer: bannedByServer, + IsTimeout: boolValue(req.Timeout, req.IsTimeout), + TokenRequestSuccess: boolPtrValue(req.TokenRequestSuccess, req.TokenSuccess), + NetworkType: trimMax(req.NetworkType, 64), + ReqClient: trimMax(meta.ReqClient, 32), + ReqAppIntel: trimMax(meta.ReqAppIntel, 255), + ClientIP: trimMax(meta.ClientIP, 64), + UserAgent: trimMax(meta.UserAgent, 255), + RawPayload: strings.TrimSpace(meta.RawPayload), + CreateTime: now, + UpdateTime: now, + } + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + return nil, err + } + return &AgoraErrorReportResponse{ + ID: strconv.FormatInt(id, 10), + BannedByServer: bannedByServer, + }, nil +} + +// PageAgoraErrorLogs returns admin Agora error logs. +func (s *Service) PageAgoraErrorLogs(ctx context.Context, query AgoraErrorLogQuery) (*AgoraErrorLogPageResponse, error) { + if s == nil || s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable") + } + cursor, limit := normalizePage(query.Cursor, query.Limit) + startTime, endTime, err := parseAdminTimeRange(query.StartTime, query.EndTime) + if err != nil { + return nil, err + } + filter := query + filter.Cursor = cursor + filter.Limit = limit + filter.StartAt = startTime + filter.EndAt = endTime + + base := s.agoraErrorLogQuery(ctx, filter) + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.AgoraErrorLog + if err := s.agoraErrorLogQuery(ctx, filter). + Order("create_time desc, id desc"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + return &AgoraErrorLogPageResponse{ + Records: agoraErrorLogItems(rows), + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +// AgoraErrorLogQuery is the admin filter for Agora error logs. +type AgoraErrorLogQuery struct { + SysOrigin string + UserID int64 + RoomID string + AgoraUID string + ErrorCodeType string + ReasonType string + BannedOnly bool + NetworkType string + StartTime string + EndTime string + Cursor int + Limit int + StartAt time.Time + EndAt time.Time +} + +func (s *Service) agoraErrorLogQuery(ctx context.Context, filter AgoraErrorLogQuery) *gorm.DB { + query := s.db.WithContext(ctx).Model(&model.AgoraErrorLog{}). + Where("create_time >= ? AND create_time <= ?", filter.StartAt, filter.EndAt) + if sysOrigin := normalizeSysOrigin(filter.SysOrigin); sysOrigin != "" { + query = query.Where("sys_origin = ?", sysOrigin) + } + if filter.UserID > 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if roomID := strings.TrimSpace(filter.RoomID); roomID != "" { + query = query.Where("room_id = ?", roomID) + } + if agoraUID := strings.TrimSpace(filter.AgoraUID); agoraUID != "" { + query = query.Where("agora_uid = ?", agoraUID) + } + if codeType := strings.TrimSpace(filter.ErrorCodeType); codeType != "" { + query = query.Where("error_code_type LIKE ?", "%"+codeType+"%") + } + if reasonType := strings.TrimSpace(filter.ReasonType); reasonType != "" { + query = query.Where("connection_changed_reason_type LIKE ?", "%"+reasonType+"%") + } + if filter.BannedOnly { + query = query.Where("banned_by_server = ?", true) + } + if networkType := strings.TrimSpace(filter.NetworkType); networkType != "" { + query = query.Where("network_type = ?", networkType) + } + return query +} + +func agoraErrorLogItems(rows []model.AgoraErrorLog) []AgoraErrorLogItem { + items := make([]AgoraErrorLogItem, 0, len(rows)) + for _, row := range rows { + items = append(items, AgoraErrorLogItem{ + ID: strconv.FormatInt(row.ID, 10), + SysOrigin: row.SysOrigin, + UserID: strconv.FormatInt(row.UserID, 10), + RoomID: row.RoomID, + AgoraUID: row.AgoraUID, + ErrorCodeType: row.ErrorCodeType, + ConnectionChangedReasonType: row.ConnectionChangedReasonType, + BannedByServer: row.BannedByServer, + IsTimeout: row.IsTimeout, + TokenRequestSuccess: row.TokenRequestSuccess, + NetworkType: row.NetworkType, + ReqClient: row.ReqClient, + ReqAppIntel: row.ReqAppIntel, + ClientIP: row.ClientIP, + UserAgent: row.UserAgent, + RawPayload: row.RawPayload, + CreateTime: formatDateTime(row.CreateTime), + UpdateTime: formatDateTime(row.UpdateTime), + }) + } + return items +} diff --git a/internal/service/errorlog/agora_test.go b/internal/service/errorlog/agora_test.go new file mode 100644 index 0000000..eeeaa66 --- /dev/null +++ b/internal/service/errorlog/agora_test.go @@ -0,0 +1,84 @@ +package errorlog + +import ( + "context" + "testing" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestReportAgoraErrorDetectsBannedByServer(t *testing.T) { + service := newTestService(t) + tokenOK := false + timeout := true + + resp, err := service.ReportAgoraError(context.Background(), common.AuthUser{ + UserID: 1234567, + SysOrigin: "LIKEI", + }, AgoraErrorReportRequest{ + RoomID: StringValue("9001"), + AgoraUID: StringValue("8888"), + ErrorCodeType: "ERR_LEAVE_CHANNEL_REJECTED", + ConnectionChangedReasonType: "BannedByServer(3)", + Timeout: &timeout, + TokenRequestSuccess: &tokenOK, + NetworkType: "wifi", + }, AgoraErrorReportContext{ + ReqClient: "Android", + ReqAppIntel: "version=1.0.0;build=100", + ClientIP: "127.0.0.1", + RawPayload: `{"roomId":"9001","connectionChangedReasonType":"BannedByServer(3)"}`, + }) + if err != nil { + t.Fatalf("ReportAgoraError returned error: %v", err) + } + if !resp.BannedByServer { + t.Fatalf("BannedByServer = false, want true") + } + + page, err := service.PageAgoraErrorLogs(context.Background(), AgoraErrorLogQuery{ + SysOrigin: "LIKEI", + BannedOnly: true, + Cursor: 1, + Limit: 20, + }) + if err != nil { + t.Fatalf("PageAgoraErrorLogs returned error: %v", err) + } + if page.Total != 1 || len(page.Records) != 1 { + t.Fatalf("page total=%d records=%d, want 1", page.Total, len(page.Records)) + } + record := page.Records[0] + if record.RoomID != "9001" || record.AgoraUID != "8888" || !record.IsTimeout { + t.Fatalf("unexpected record: %+v", record) + } + if record.TokenRequestSuccess == nil || *record.TokenRequestSuccess { + t.Fatalf("TokenRequestSuccess = %v, want false", record.TokenRequestSuccess) + } +} + +func TestStringValueAcceptsNumericJSON(t *testing.T) { + var value StringValue + if err := value.UnmarshalJSON([]byte(`1838887943867809794`)); err != nil { + t.Fatalf("UnmarshalJSON returned error: %v", err) + } + if value.String() != "1838887943867809794" { + t.Fatalf("value = %q, want numeric string", value.String()) + } +} + +func newTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.AgoraErrorLog{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewService(db) +} diff --git a/internal/service/errorlog/types.go b/internal/service/errorlog/types.go new file mode 100644 index 0000000..0426fe5 --- /dev/null +++ b/internal/service/errorlog/types.go @@ -0,0 +1,263 @@ +package errorlog + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + + "gorm.io/gorm" +) + +const adminTimeLayout = "2006-01-02 15:04:05" + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type dbHandle interface { + WithContext(ctx context.Context) *gorm.DB +} + +// Service handles app error logs. +type Service struct { + db dbHandle +} + +// NewService creates an app error log service. +func NewService(db dbHandle) *Service { + return &Service{db: db} +} + +// StringValue accepts JSON string or number and stores it as a string. +type StringValue string + +func (v StringValue) String() string { + return strings.TrimSpace(string(v)) +} + +// UnmarshalJSON accepts quoted strings and numeric IDs from clients. +func (v *StringValue) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = "" + return nil + } + if strings.HasPrefix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + *v = StringValue(strings.TrimSpace(unquoted)) + return nil + } + *v = StringValue(raw) + return nil +} + +// AgoraErrorReportRequest is the client report payload for Agora errors. +type AgoraErrorReportRequest struct { + RoomID StringValue `json:"roomId"` + AgoraUID StringValue `json:"agoraUid"` + ErrorCodeType string `json:"errorCodeType"` + ConnectionChangedReasonType string `json:"connectionChangedReasonType"` + ReasonType string `json:"reasonType"` + Reason string `json:"reason"` + Timeout *bool `json:"timeout"` + IsTimeout *bool `json:"isTimeout"` + TokenRequestSuccess *bool `json:"tokenRequestSuccess"` + TokenSuccess *bool `json:"tokenSuccess"` + NetworkType string `json:"networkType"` +} + +// AgoraErrorReportContext carries request metadata captured at the router layer. +type AgoraErrorReportContext struct { + ReqClient string + ReqAppIntel string + ClientIP string + UserAgent string + RawPayload string +} + +// AgoraErrorReportResponse is returned after a report is persisted. +type AgoraErrorReportResponse struct { + ID string `json:"id"` + BannedByServer bool `json:"bannedByServer"` +} + +// AgoraErrorLogPageResponse is the admin page response. +type AgoraErrorLogPageResponse struct { + Records []AgoraErrorLogItem `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +// AgoraErrorLogItem is an admin-readable Agora error log. +type AgoraErrorLogItem struct { + ID string `json:"id"` + SysOrigin string `json:"sysOrigin"` + UserID string `json:"userId"` + RoomID string `json:"roomId"` + AgoraUID string `json:"agoraUid"` + ErrorCodeType string `json:"errorCodeType"` + ConnectionChangedReasonType string `json:"connectionChangedReasonType"` + BannedByServer bool `json:"bannedByServer"` + IsTimeout bool `json:"isTimeout"` + TokenRequestSuccess *bool `json:"tokenRequestSuccess"` + NetworkType string `json:"networkType"` + ReqClient string `json:"reqClient"` + ReqAppIntel string `json:"reqAppIntel"` + ClientIP string `json:"clientIp"` + UserAgent string `json:"userAgent"` + RawPayload string `json:"rawPayload"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` +} + +func normalizePage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func normalizeSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +func trimMax(value string, max int) string { + value = strings.TrimSpace(value) + if max > 0 && len(value) > max { + return value[:max] + } + return value +} + +func boolValue(primary *bool, aliases ...*bool) bool { + if primary != nil { + return *primary + } + for _, alias := range aliases { + if alias != nil { + return *alias + } + } + return false +} + +func boolPtrValue(primary *bool, aliases ...*bool) *bool { + if primary != nil { + return primary + } + for _, alias := range aliases { + if alias != nil { + return alias + } + } + return nil +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func isBannedByServer(reason string) bool { + normalized := strings.ToLower(strings.TrimSpace(reason)) + if normalized == "" { + return false + } + return normalized == "3" || + strings.Contains(normalized, "bannedbyserver") || + strings.Contains(normalized, "banned_by_server") || + strings.Contains(normalized, "banned by server") +} + +func formatDateTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(adminTimeLayout) +} + +func parseAdminTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) { + startRaw = strings.TrimSpace(startRaw) + endRaw = strings.TrimSpace(endRaw) + now := time.Now() + if startRaw == "" && endRaw == "" { + return now.AddDate(0, 0, -7), now, nil + } + + var start time.Time + var end time.Time + var err error + if startRaw != "" { + start, err = parseAdminTime(startRaw, false) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if endRaw != "" { + end, err = parseAdminTime(endRaw, true) + if err != nil { + return time.Time{}, time.Time{}, err + } + } + if startRaw == "" { + start = end.AddDate(0, 0, -7) + } + if endRaw == "" { + end = start.AddDate(0, 0, 7) + } + if end.Before(start) { + return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime") + } + return start, end, nil +} + +func parseAdminTime(raw string, endOfDay bool) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + return time.UnixMilli(millis), nil + } + for _, layout := range []string{ + adminTimeLayout, + time.RFC3339, + "2006-01-02T15:04:05.000Z07:00", + "2006-01-02", + } { + var parsed time.Time + var err error + if strings.Contains(layout, "Z07:00") || layout == time.RFC3339 { + parsed, err = time.Parse(layout, raw) + } else { + parsed, err = time.ParseInLocation(layout, raw, time.Local) + } + if err == nil { + if layout == "2006-01-02" && endOfDay { + parsed = parsed.Add(24*time.Hour - time.Nanosecond) + } + return parsed, nil + } + } + return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw)) +} diff --git a/internal/service/taskcenter/admin.go b/internal/service/taskcenter/admin.go index 703c288..cb00f30 100644 --- a/internal/service/taskcenter/admin.go +++ b/internal/service/taskcenter/admin.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "sort" + "strconv" "strings" "time" @@ -195,25 +196,36 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi } // PageClaimRecords 分页查询领取记录。 -func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, status string, cursor int, limit int) (*PageResponse[ClaimRecordView], error) { +func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, status string, startTime string, endTime string, cursor int, limit int) (*ClaimRecordPageResponse, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) category := normalizeCategory(taskCategory) if err := validateCategory(category); err != nil { return nil, err } cursor, limit = NormalizePage(cursor, limit) + startAt, endAt, err := parseClaimRecordTimeRange(startTime, endTime) + if err != nil { + return nil, err + } + code := strings.ToUpper(strings.TrimSpace(taskCode)) query := s.db.WithContext(ctx).Model(&model.TaskCenterClaimRecord{}). Where("sys_origin = ? AND task_category = ?", sysOrigin, category) if userID > 0 { query = query.Where("user_id = ?", userID) } - if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" { + if code != "" { query = query.Where("task_code = ?", code) } if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" { query = query.Where("status = ?", normalizedStatus) } + if !startAt.IsZero() { + query = query.Where("create_time >= ?", startAt) + } + if !endAt.IsZero() { + query = query.Where("create_time <= ?", endAt) + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -230,7 +242,52 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCa for _, row := range rows { records = append(records, claimRecordView(row)) } - return &PageResponse[ClaimRecordView]{Records: records, Total: total, Current: cursor, Size: limit}, nil + summary, err := s.claimRecordRewardSummary(ctx, sysOrigin, category, userID, code, startAt, endAt) + if err != nil { + return nil, err + } + return &ClaimRecordPageResponse{Records: records, Total: total, Current: cursor, Size: limit, Summary: summary}, nil +} + +func (s *Service) claimRecordRewardSummary(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, startAt time.Time, endAt time.Time) (ClaimRecordSummary, error) { + total, err := s.claimRecordRewardGoldSum(ctx, sysOrigin, taskCategory, userID, "", startAt, endAt) + if err != nil { + return ClaimRecordSummary{}, err + } + selected := int64(0) + if taskCode != "" { + selected, err = s.claimRecordRewardGoldSum(ctx, sysOrigin, taskCategory, userID, taskCode, startAt, endAt) + if err != nil { + return ClaimRecordSummary{}, err + } + } + return ClaimRecordSummary{ + TotalRewardGold: total, + SelectedTaskRewardGold: selected, + TaskCode: taskCode, + }, nil +} + +func (s *Service) claimRecordRewardGoldSum(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, startAt time.Time, endAt time.Time) (int64, error) { + query := s.db.WithContext(ctx).Model(&model.TaskCenterClaimRecord{}). + Where("sys_origin = ? AND task_category = ? AND status = ?", sysOrigin, taskCategory, ClaimStatusSuccess) + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if strings.TrimSpace(taskCode) != "" { + query = query.Where("task_code = ?", strings.ToUpper(strings.TrimSpace(taskCode))) + } + if !startAt.IsZero() { + query = query.Where("create_time >= ?", startAt) + } + if !endAt.IsZero() { + query = query.Where("create_time <= ?", endAt) + } + var total int64 + if err := query.Select("COALESCE(SUM(reward_gold), 0)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil } func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) { @@ -579,6 +636,67 @@ func normalizeConditionJumpPage(jumpType string, jumpPage string) string { return strings.TrimSpace(jumpPage) } +func parseClaimRecordTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) { + var start time.Time + var end time.Time + var err error + if strings.TrimSpace(startRaw) != "" { + start, err = parseClaimRecordTime(startRaw, false) + if err != nil { + return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_start_time", "startTime is invalid") + } + } + if strings.TrimSpace(endRaw) != "" { + end, err = parseClaimRecordTime(endRaw, true) + if err != nil { + return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_end_time", "endTime is invalid") + } + } + if !start.IsZero() && !end.IsZero() && 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 parseClaimRecordTime(raw string, endOfDay bool) (time.Time, error) { + value := strings.TrimSpace(raw) + if value == "" { + return time.Time{}, nil + } + if millis, err := strconv.ParseInt(value, 10, 64); err == nil { + if millis > 1_000_000_000_000 { + return time.UnixMilli(millis), nil + } + return time.Unix(millis, 0), nil + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } + var lastErr error + for _, layout := range layouts { + var parsed time.Time + var err error + if layout == time.RFC3339Nano || layout == time.RFC3339 { + parsed, err = time.Parse(layout, value) + } else { + parsed, err = time.ParseInLocation(layout, value, time.Local) + } + if err != nil { + lastErr = err + continue + } + if layout == "2006-01-02" && endOfDay { + parsed = parsed.Add(24*time.Hour - time.Nanosecond) + } + return parsed, nil + } + return time.Time{}, lastErr +} + func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView { return ClaimRecordView{ ID: row.ID, diff --git a/internal/service/taskcenter/taskcenter_test.go b/internal/service/taskcenter/taskcenter_test.go index cf1d5ed..de438c5 100644 --- a/internal/service/taskcenter/taskcenter_test.go +++ b/internal/service/taskcenter/taskcenter_test.go @@ -410,6 +410,107 @@ func TestConditionJumpConfigAppliesToTasks(t *testing.T) { } } +func TestPageClaimRecordsFiltersTimeAndSummarizesGold(t *testing.T) { + service, db := newTestService(t, nil) + ctx := context.Background() + base := time.Date(2026, 5, 15, 10, 0, 0, 0, time.Local) + claimedAt := base + rows := []model.TaskCenterClaimRecord{ + { + ID: 1, + SysOrigin: "LIKEI", + UserID: 1001, + TaskID: 11, + TaskCode: "DAILY_MIC", + TaskName: "Mic", + TaskCategory: TaskCategoryDaily, + CycleKey: "20260515", + RewardGold: 100, + WalletEventID: "wallet-1", + Status: ClaimStatusSuccess, + ClaimedAt: &claimedAt, + CreateTime: base, + UpdateTime: base, + }, + { + ID: 2, + SysOrigin: "LIKEI", + UserID: 1002, + TaskID: 12, + TaskCode: "DAILY_GIFT", + TaskName: "Gift", + TaskCategory: TaskCategoryDaily, + CycleKey: "20260515", + RewardGold: 200, + WalletEventID: "wallet-2", + Status: ClaimStatusSuccess, + ClaimedAt: &claimedAt, + CreateTime: base.Add(time.Hour), + UpdateTime: base.Add(time.Hour), + }, + { + ID: 3, + SysOrigin: "LIKEI", + UserID: 1003, + TaskID: 11, + TaskCode: "DAILY_MIC", + TaskName: "Mic", + TaskCategory: TaskCategoryDaily, + CycleKey: "20260515", + RewardGold: 999, + WalletEventID: "wallet-3", + Status: ClaimStatusFailed, + FailureReason: "wallet failed", + CreateTime: base.Add(2 * time.Hour), + UpdateTime: base.Add(2 * time.Hour), + }, + { + ID: 4, + SysOrigin: "LIKEI", + UserID: 1004, + TaskID: 13, + TaskCode: "DAILY_OLD", + TaskName: "Old", + TaskCategory: TaskCategoryDaily, + CycleKey: "20260514", + RewardGold: 700, + WalletEventID: "wallet-4", + Status: ClaimStatusSuccess, + ClaimedAt: &claimedAt, + CreateTime: base.AddDate(0, 0, -1), + UpdateTime: base.AddDate(0, 0, -1), + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("create claim records: %v", err) + } + + resp, err := service.PageClaimRecords( + ctx, + "LIKEI", + TaskCategoryDaily, + 0, + "daily_mic", + "", + base.Format("2006-01-02 15:04:05"), + base.Add(3*time.Hour).Format("2006-01-02 15:04:05"), + 1, + 20, + ) + if err != nil { + t.Fatalf("PageClaimRecords() error = %v", err) + } + if resp.Total != 2 || len(resp.Records) != 2 { + t.Fatalf("records total=%d len=%d, want 2", resp.Total, len(resp.Records)) + } + if resp.Summary.TotalRewardGold != 300 { + t.Fatalf("total reward gold = %d, want 300", resp.Summary.TotalRewardGold) + } + if resp.Summary.SelectedTaskRewardGold != 100 { + t.Fatalf("selected task reward gold = %d, want 100", resp.Summary.SelectedTaskRewardGold) + } +} + func TestSaveConfigDerivesConditionJumpsWhenPayloadOmitted(t *testing.T) { service, _ := newTestService(t, nil) ctx := context.Background() diff --git a/internal/service/taskcenter/types.go b/internal/service/taskcenter/types.go index 8ce658b..573cf25 100644 --- a/internal/service/taskcenter/types.go +++ b/internal/service/taskcenter/types.go @@ -216,6 +216,20 @@ type PageResponse[T any] struct { Size int `json:"size"` } +type ClaimRecordSummary struct { + TotalRewardGold int64 `json:"totalRewardGold"` + SelectedTaskRewardGold int64 `json:"selectedTaskRewardGold"` + TaskCode string `json:"taskCode,omitempty"` +} + +type ClaimRecordPageResponse struct { + Records []ClaimRecordView `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` + Summary ClaimRecordSummary `json:"summary"` +} + type ClaimRecordView struct { ID int64 `json:"id,string"` SysOrigin string `json:"sysOrigin"` diff --git a/internal/service/userbadge/current.go b/internal/service/userbadge/current.go index 9bcd743..ad592f2 100644 --- a/internal/service/userbadge/current.go +++ b/internal/service/userbadge/current.go @@ -35,13 +35,31 @@ func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeList if err != nil { return nil, err } + return s.currentForUser(ctx, sysOrigin, user.UserID) +} - now := s.now() - longBadges, vipBadgeID, err := s.loadLongBadges(ctx, sysOrigin, user.UserID, now) +// 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 } - shortBadges, err := s.loadShortBadges(ctx, sysOrigin, user.UserID, vipBadgeID, now) + 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 } @@ -51,7 +69,7 @@ func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeList badges = append(badges, shortBadges...) return &CurrentBadgeListResponse{ - UserID: user.UserID, + UserID: userID, SysOrigin: sysOrigin, Badges: badges, LongBadges: longBadges, diff --git a/internal/service/userbadge/current_test.go b/internal/service/userbadge/current_test.go index 62bae6b..f449ab4 100644 --- a/internal/service/userbadge/current_test.go +++ b/internal/service/userbadge/current_test.go @@ -104,6 +104,57 @@ func TestCurrentBadgesRejectsInvalidUser(t *testing.T) { } } +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{}) diff --git a/internal/service/userbadge/types.go b/internal/service/userbadge/types.go index 9a68a92..5847612 100644 --- a/internal/service/userbadge/types.go +++ b/internal/service/userbadge/types.go @@ -98,6 +98,19 @@ func formatID(id int64) string { 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 diff --git a/internal/service/voiceroomrocket/config_test.go b/internal/service/voiceroomrocket/config_test.go index ee0da8b..597fd50 100644 --- a/internal/service/voiceroomrocket/config_test.go +++ b/internal/service/voiceroomrocket/config_test.go @@ -42,3 +42,44 @@ func TestSaveLevelConfigsPersistsPreviewRocketURL(t *testing.T) { t.Fatalf("listed levels = %+v", listed) } } + +func TestGetStatusReturnsRocketResourcesToApp(t *testing.T) { + service, _ := newTestService(t) + ctx := context.Background() + + _, err := service.SaveLevelConfigs(ctx, SaveLevelConfigsRequest{ + SysOrigin: "LIKEI", + Levels: []LevelConfigPayload{ + { + Level: 1, + NeedEnergy: 100, + ShakeThresholdPercent: "95.0000", + PreviewRocketURL: "https://cdn.example.com/preview-rocket.png", + RocketIconURL: "https://cdn.example.com/rocket-icon.png", + RocketAnimationURL: "https://cdn.example.com/rocket-launch.svga", + Enabled: true, + Sort: 1, + }, + }, + }) + if err != nil { + t.Fatalf("SaveLevelConfigs error = %v", err) + } + + status, err := service.GetStatus(ctx, AuthUser{SysOrigin: "LIKEI"}, 9001) + if err != nil { + t.Fatalf("GetStatus error = %v", err) + } + if status.PreviewRocketURL != "https://cdn.example.com/preview-rocket.png" { + t.Fatalf("status previewRocketUrl = %q", status.PreviewRocketURL) + } + if status.RocketIconURL != "https://cdn.example.com/rocket-icon.png" { + t.Fatalf("status rocketIconUrl = %q", status.RocketIconURL) + } + if status.RocketAnimationURL != "https://cdn.example.com/rocket-launch.svga" { + t.Fatalf("status rocketAnimationUrl = %q", status.RocketAnimationURL) + } + if len(status.Levels) != 1 || status.Levels[0].PreviewRocketURL != status.PreviewRocketURL { + t.Fatalf("status levels = %+v", status.Levels) + } +} diff --git a/internal/service/voiceroomrocket/event_test.go b/internal/service/voiceroomrocket/event_test.go index 01ee1ad..65a8981 100644 --- a/internal/service/voiceroomrocket/event_test.go +++ b/internal/service/voiceroomrocket/event_test.go @@ -255,6 +255,67 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { } } +func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) { + gateway := newFakeRocketGateway() + service, db := newTestService(t) + service.rewardGateway = gateway + + ctx := context.Background() + badgeID := int64(3001) + expireDays := 14 + record := model.VoiceRoomRocketRewardRecord{ + ID: 8801, + LaunchID: 9901, + LaunchNo: "launch-badge", + SysOrigin: "LIKEI", + RoomID: 9001, + DayKey: "20260515", + RoundNo: 1, + Level: 1, + UserID: 1001, + RewardScene: rewardSceneTop1, + RewardConfigID: 7701, + RewardType: rewardTypeBadge, + RewardItemID: &badgeID, + RewardName: "activity badge", + RewardAmount: 1, + ExpireDays: &expireDays, + GrantEventID: "VOICE_ROOM_ROCKET:launch-badge:TOP1:1001:7701", + GrantStatus: rewardStatusPending, + PopupStatus: popupStatusUnread, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if err := db.Create(&record).Error; err != nil { + t.Fatalf("seed reward record: %v", err) + } + + granted, err := service.ProcessPendingRewards(ctx, 10) + if err != nil { + t.Fatalf("ProcessPendingRewards() error = %v", err) + } + if granted != 1 { + t.Fatalf("granted = %d, want 1", granted) + } + if len(gateway.badges) != 1 { + t.Fatalf("badge grants = %d, want 1", len(gateway.badges)) + } + if gateway.badges[0] != (fakeBadgeGrant{userID: 1001, badgeID: badgeID, days: expireDays}) { + t.Fatalf("badge grant = %+v, want user 1001 badge %d days %d", gateway.badges[0], badgeID, expireDays) + } + if len(gateway.props) != 0 { + t.Fatalf("props grants = %d, want 0", len(gateway.props)) + } + + var latest model.VoiceRoomRocketRewardRecord + if err := db.Where("id = ?", record.ID).First(&latest).Error; err != nil { + t.Fatalf("load reward record: %v", err) + } + if latest.GrantStatus != rewardStatusSuccess { + t.Fatalf("grant status = %s, want %s", latest.GrantStatus, rewardStatusSuccess) + } +} + func TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) { gateway := newFakeRocketGateway() service, db, _, mr := newTestServiceWithRedis(t, gateway) @@ -419,6 +480,23 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { service, db, rdb, mr := newTestServiceWithRedis(t, gateway) defer mr.Close() seedRocketConfig(t, db) + now := time.Now() + if err := db.Create(&model.VoiceRoomRocketLevelConfig{ + ID: 991, + SysOrigin: "LIKEI", + Level: 1, + NeedEnergy: 100, + ShakeThresholdPercent: "95.0000", + PreviewRocketURL: "https://cdn.example.com/preview-rocket.png", + RocketIconURL: "https://cdn.example.com/rocket-icon.png", + RocketAnimationURL: "https://cdn.example.com/rocket-launch.svga", + Enabled: true, + Sort: 1, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed level config: %v", err) + } fakeIM := &fakeRocketIM{} service.SetIMGateway(fakeIM) @@ -438,6 +516,23 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" { t.Fatalf("im messages = %+v", fakeIM.messages) } + body, ok := fakeIM.messages[0].body.(map[string]any) + if !ok { + t.Fatalf("im body = %#v", fakeIM.messages[0].body) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("im data = %#v", body["data"]) + } + if data["previewRocketUrl"] != "https://cdn.example.com/preview-rocket.png" { + t.Fatalf("previewRocketUrl = %#v", data["previewRocketUrl"]) + } + if data["rocketIconUrl"] != "https://cdn.example.com/rocket-icon.png" { + t.Fatalf("rocketIconUrl = %#v", data["rocketIconUrl"]) + } + if data["rocketAnimationUrl"] != "https://cdn.example.com/rocket-launch.svga" { + t.Fatalf("rocketAnimationUrl = %#v", data["rocketAnimationUrl"]) + } count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result() if err != nil { t.Fatalf("xlen broadcast: %v", err) @@ -649,7 +744,13 @@ func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service type fakeRocketGateway struct { goldEvents map[string]integration.GoldReceiptCommand props []integration.GivePropsBackpackRequest - badges []int64 + badges []fakeBadgeGrant +} + +type fakeBadgeGrant struct { + userID int64 + badgeID int64 + days int } type fakeRocketIM struct { @@ -716,7 +817,7 @@ func (g *fakeRocketGateway) SwitchUseProps(ctx context.Context, userID int64, pr } func (g *fakeRocketGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { - g.badges = append(g.badges, badgeID) + g.badges = append(g.badges, fakeBadgeGrant{userID: userID, badgeID: badgeID, days: days}) return nil } diff --git a/internal/service/voiceroomrocket/notify.go b/internal/service/voiceroomrocket/notify.go index 71ef797..bf92a7f 100644 --- a/internal/service/voiceroomrocket/notify.go +++ b/internal/service/voiceroomrocket/notify.go @@ -120,18 +120,29 @@ func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocket if launch.IgniteUserID != nil { triggerUserID = strconv.FormatInt(*launch.IgniteUserID, 10) } + previewRocketURL := "" + rocketIconURL := "" + rocketAnimationURL := "" + if levelConfig, err := s.loadLevelConfig(ctx, launch.SysOrigin, launch.Level); err == nil { + previewRocketURL = levelConfig.PreviewRocketURL + rocketIconURL = levelConfig.RocketIconURL + rocketAnimationURL = levelConfig.RocketAnimationURL + } body := map[string]any{ "type": imTypeLaunchBroadcast, "data": map[string]any{ - "roomId": strconv.FormatInt(launch.RoomID, 10), - "roomAccount": launch.RoomAccount, - "roomName": roomName, - "roundNo": launch.RoundNo, - "level": launch.Level, - "durationSeconds": durationSeconds, - "triggerUser": triggerUser, - "triggerUserId": triggerUserID, - "launchNo": launch.LaunchNo, + "roomId": strconv.FormatInt(launch.RoomID, 10), + "roomAccount": launch.RoomAccount, + "roomName": roomName, + "roundNo": launch.RoundNo, + "level": launch.Level, + "durationSeconds": durationSeconds, + "previewRocketUrl": previewRocketURL, + "rocketIconUrl": rocketIconURL, + "rocketAnimationUrl": rocketAnimationURL, + "triggerUser": triggerUser, + "triggerUserId": triggerUserID, + "launchNo": launch.LaunchNo, }, } if s.repo.Redis != nil { diff --git a/internal/service/voiceroomrocket/status.go b/internal/service/voiceroomrocket/status.go index 9b130cf..9a2e319 100644 --- a/internal/service/voiceroomrocket/status.go +++ b/internal/service/voiceroomrocket/status.go @@ -58,6 +58,9 @@ func (s *Service) GetStatus(ctx context.Context, user AuthUser, roomID int64) (* DisplayPercent: displayPercent, LaunchCountdownSeconds: countdownSeconds, Shake: shake, + PreviewRocketURL: currentLevelConfig.PreviewRocketURL, + RocketIconURL: currentLevelConfig.RocketIconURL, + RocketAnimationURL: currentLevelConfig.RocketAnimationURL, Levels: buildLevelPayloads(levels), RewardPreview: rewardPreviewPayload(rewards), RocketKings: kings, diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go index 0d3352b..8f28767 100644 --- a/internal/service/voiceroomrocket/types.go +++ b/internal/service/voiceroomrocket/types.go @@ -403,6 +403,9 @@ type StatusResponse struct { DisplayPercent int `json:"displayPercent"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"` Shake bool `json:"shake"` + PreviewRocketURL string `json:"previewRocketUrl,omitempty"` + RocketIconURL string `json:"rocketIconUrl,omitempty"` + RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"` Levels []LevelConfigPayload `json:"levels"` RewardPreview RewardPreviewResponse `json:"rewardPreview"` RocketKings []KingRecord `json:"rocketKings"` diff --git a/migrations/040_agora_error_log.sql b/migrations/040_agora_error_log.sql new file mode 100644 index 0000000..493a2c1 --- /dev/null +++ b/migrations/040_agora_error_log.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS `agora_error_log` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `sys_origin` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '系统来源', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '上报用户ID', + `room_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '房间ID', + `agora_uid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Agora UID', + `error_code_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ErrorCodeType', + `connection_changed_reason_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ConnectionChangedReasonType', + `banned_by_server` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 BannedByServer/kicking-rule', + `is_timeout` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超时', + `token_request_success` TINYINT(1) DEFAULT NULL COMMENT 'token 请求是否成功', + `network_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '网络类型', + `req_client` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '请求客户端', + `req_app_intel` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'App版本信息请求头', + `client_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'User-Agent', + `raw_payload` MEDIUMTEXT COMMENT '原始上报内容', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_agora_error_sys_time` (`sys_origin`, `create_time`), + KEY `idx_agora_error_user_time` (`user_id`, `create_time`), + KEY `idx_agora_error_room_time` (`room_id`, `create_time`), + KEY `idx_agora_error_code_time` (`error_code_type`, `create_time`), + KEY `idx_agora_error_reason` (`connection_changed_reason_type`), + KEY `idx_agora_error_banned_time` (`banned_by_server`, `create_time`), + KEY `idx_agora_error_network` (`network_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agora错误日志'; diff --git a/migrations/041_task_center_claim_record_admin_indexes.sql b/migrations/041_task_center_claim_record_admin_indexes.sql new file mode 100644 index 0000000..5e264eb --- /dev/null +++ b/migrations/041_task_center_claim_record_admin_indexes.sql @@ -0,0 +1,46 @@ +SET @current_schema := DATABASE(); + +SET @add_task_center_claim_admin_time_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'task_center_claim_record' + AND index_name = 'idx_task_center_claim_admin_time' + ), + 'ALTER TABLE `task_center_claim_record` ADD INDEX `idx_task_center_claim_admin_time` (`sys_origin`, `task_category`, `create_time`, `id`)', + 'SELECT 1' +); +PREPARE add_task_center_claim_admin_time_stmt FROM @add_task_center_claim_admin_time_sql; +EXECUTE add_task_center_claim_admin_time_stmt; +DEALLOCATE PREPARE add_task_center_claim_admin_time_stmt; + +SET @add_task_center_claim_admin_task_time_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'task_center_claim_record' + AND index_name = 'idx_task_center_claim_admin_task_time' + ), + 'ALTER TABLE `task_center_claim_record` ADD INDEX `idx_task_center_claim_admin_task_time` (`sys_origin`, `task_category`, `task_code`, `create_time`, `id`)', + 'SELECT 1' +); +PREPARE add_task_center_claim_admin_task_time_stmt FROM @add_task_center_claim_admin_task_time_sql; +EXECUTE add_task_center_claim_admin_task_time_stmt; +DEALLOCATE PREPARE add_task_center_claim_admin_task_time_stmt; + +SET @add_task_center_claim_admin_status_time_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @current_schema + AND table_name = 'task_center_claim_record' + AND index_name = 'idx_task_center_claim_admin_status_time' + ), + 'ALTER TABLE `task_center_claim_record` ADD INDEX `idx_task_center_claim_admin_status_time` (`sys_origin`, `task_category`, `status`, `create_time`)', + 'SELECT 1' +); +PREPARE add_task_center_claim_admin_status_time_stmt FROM @add_task_center_claim_admin_status_time_sql; +EXECUTE add_task_center_claim_admin_status_time_stmt; +DEALLOCATE PREPARE add_task_center_claim_admin_status_time_stmt;