新增声网报错
This commit is contained in:
parent
1abc344d05
commit
bbf50367e2
@ -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"
|
||||||
@ -48,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)
|
||||||
@ -102,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,
|
||||||
|
|||||||
28
internal/model/agora_error_log_models.go
Normal file
28
internal/model/agora_error_log_models.go
Normal 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" }
|
||||||
81
internal/router/agora_error_log_routes.go
Normal file
81
internal/router/agora_error_log_routes.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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"
|
||||||
@ -37,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
|
||||||
@ -74,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)
|
||||||
|
|||||||
@ -121,6 +121,8 @@ func registerTaskCenterRoutes(engine *gin.Engine, cfg config.Config, javaClient
|
|||||||
taskcenter.ParseInt64(c.Query("userId")),
|
taskcenter.ParseInt64(c.Query("userId")),
|
||||||
c.Query("taskCode"),
|
c.Query("taskCode"),
|
||||||
c.Query("status"),
|
c.Query("status"),
|
||||||
|
c.Query("startTime"),
|
||||||
|
c.Query("endTime"),
|
||||||
int(taskcenter.ParseInt64(c.Query("cursor"))),
|
int(taskcenter.ParseInt64(c.Query("cursor"))),
|
||||||
int(taskcenter.ParseInt64(c.Query("limit"))),
|
int(taskcenter.ParseInt64(c.Query("limit"))),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,4 +22,16 @@ func registerUserBadgeRoutes(engine *gin.Engine, javaClient authGateway, service
|
|||||||
}
|
}
|
||||||
writeOK(c, resp)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
func newRouterUserBadgeDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
|||||||
173
internal/service/errorlog/agora.go
Normal file
173
internal/service/errorlog/agora.go
Normal 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
|
||||||
|
}
|
||||||
84
internal/service/errorlog/agora_test.go
Normal file
84
internal/service/errorlog/agora_test.go
Normal 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)
|
||||||
|
}
|
||||||
263
internal/service/errorlog/types.go
Normal file
263
internal/service/errorlog/types.go
Normal 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))
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -195,25 +196,36 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PageClaimRecords 分页查询领取记录。
|
// 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)
|
sysOrigin = s.normalizeSysOrigin(sysOrigin)
|
||||||
category := normalizeCategory(taskCategory)
|
category := normalizeCategory(taskCategory)
|
||||||
if err := validateCategory(category); err != nil {
|
if err := validateCategory(category); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cursor, limit = NormalizePage(cursor, limit)
|
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{}).
|
query := s.db.WithContext(ctx).Model(&model.TaskCenterClaimRecord{}).
|
||||||
Where("sys_origin = ? AND task_category = ?", sysOrigin, category)
|
Where("sys_origin = ? AND task_category = ?", sysOrigin, category)
|
||||||
if userID > 0 {
|
if userID > 0 {
|
||||||
query = query.Where("user_id = ?", userID)
|
query = query.Where("user_id = ?", userID)
|
||||||
}
|
}
|
||||||
if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" {
|
if code != "" {
|
||||||
query = query.Where("task_code = ?", code)
|
query = query.Where("task_code = ?", code)
|
||||||
}
|
}
|
||||||
if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" {
|
if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" {
|
||||||
query = query.Where("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
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
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 {
|
for _, row := range rows {
|
||||||
records = append(records, claimRecordView(row))
|
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) {
|
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)
|
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 {
|
func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView {
|
||||||
return ClaimRecordView{
|
return ClaimRecordView{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
|
|||||||
@ -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) {
|
func TestSaveConfigDerivesConditionJumpsWhenPayloadOmitted(t *testing.T) {
|
||||||
service, _ := newTestService(t, nil)
|
service, _ := newTestService(t, nil)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@ -216,6 +216,20 @@ type PageResponse[T any] struct {
|
|||||||
Size int `json:"size"`
|
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 {
|
type ClaimRecordView struct {
|
||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
SysOrigin string `json:"sysOrigin"`
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
|||||||
@ -35,13 +35,31 @@ func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeList
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return s.currentForUser(ctx, sysOrigin, user.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
now := s.now()
|
// Other returns another user's badge list in the current authenticated sysOrigin.
|
||||||
longBadges, vipBadgeID, err := s.loadLongBadges(ctx, sysOrigin, user.UserID, now)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -51,7 +69,7 @@ func (s *Service) Current(ctx context.Context, user AuthUser) (*CurrentBadgeList
|
|||||||
badges = append(badges, shortBadges...)
|
badges = append(badges, shortBadges...)
|
||||||
|
|
||||||
return &CurrentBadgeListResponse{
|
return &CurrentBadgeListResponse{
|
||||||
UserID: user.UserID,
|
UserID: userID,
|
||||||
SysOrigin: sysOrigin,
|
SysOrigin: sysOrigin,
|
||||||
Badges: badges,
|
Badges: badges,
|
||||||
LongBadges: longBadges,
|
LongBadges: longBadges,
|
||||||
|
|||||||
@ -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 {
|
func newUserBadgeTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
|||||||
@ -98,6 +98,19 @@ func formatID(id int64) string {
|
|||||||
return strconv.FormatInt(id, 10)
|
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 {
|
func formatMillis(t time.Time) int64 {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -42,3 +42,44 @@ func TestSaveLevelConfigsPersistsPreviewRocketURL(t *testing.T) {
|
|||||||
t.Fatalf("listed levels = %+v", listed)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
@ -419,6 +480,23 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
|
|||||||
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)
|
||||||
|
|
||||||
@ -438,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)
|
||||||
@ -649,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 {
|
||||||
@ -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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -403,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"`
|
||||||
|
|||||||
28
migrations/040_agora_error_log.sql
Normal file
28
migrations/040_agora_error_log.sql
Normal 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错误日志';
|
||||||
46
migrations/041_task_center_claim_record_admin_indexes.sql
Normal file
46
migrations/041_task_center_claim_record_admin_indexes.sql
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user