新增声网报错

This commit is contained in:
hy001 2026-05-15 19:42:02 +08:00
parent 1abc344d05
commit bbf50367e2
23 changed files with 1293 additions and 18 deletions

View File

@ -15,6 +15,7 @@ import (
"chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
@ -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,

View File

@ -0,0 +1,28 @@
package model
import "time"
// AgoraErrorLog stores client-reported Agora room connection errors.
type AgoraErrorLog struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
SysOrigin string `gorm:"column:sys_origin;size:32"` // 系统来源
UserID int64 `gorm:"column:user_id"` // 上报用户ID
RoomID string `gorm:"column:room_id;size:64"` // 房间ID
AgoraUID string `gorm:"column:agora_uid;size:64"` // Agora UID
ErrorCodeType string `gorm:"column:error_code_type;size:128"` // Agora ErrorCodeType
ConnectionChangedReasonType string `gorm:"column:connection_changed_reason_type;size:128"` // Agora ConnectionChangedReasonType
BannedByServer bool `gorm:"column:banned_by_server"` // 是否 BannedByServer/kicking-rule
IsTimeout bool `gorm:"column:is_timeout"` // 是否超时
TokenRequestSuccess *bool `gorm:"column:token_request_success"` // token 请求是否成功
NetworkType string `gorm:"column:network_type;size:64"` // 网络类型
ReqClient string `gorm:"column:req_client;size:32"` // 请求客户端
ReqAppIntel string `gorm:"column:req_app_intel;size:255"` // App 版本信息请求头
ClientIP string `gorm:"column:client_ip;size:64"` // 客户端IP
UserAgent string `gorm:"column:user_agent;size:255"` // User-Agent
RawPayload string `gorm:"column:raw_payload;type:mediumtext"` // 原始上报内容
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
}
// TableName returns the Agora error log table name.
func (AgoraErrorLog) TableName() string { return "agora_error_log" }

View File

@ -0,0 +1,81 @@
package router
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/errorlog"
"github.com/gin-gonic/gin"
)
// registerAgoraErrorLogRoutes registers client report and admin page APIs for Agora errors.
func registerAgoraErrorLogRoutes(engine *gin.Engine, javaClient authGateway, service *errorlog.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/agora")
appGroup.Use(authMiddleware(javaClient))
appGroup.POST("/error-log", func(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
var req errorlog.AgoraErrorReportRequest
if len(strings.TrimSpace(string(raw))) > 0 {
if err := json.Unmarshal(raw, &req); err != nil {
writeError(c, errorlog.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
}
resp, err := service.ReportAgoraError(
c.Request.Context(),
mustAuthUser(c),
req,
errorlog.AgoraErrorReportContext{
ReqClient: c.GetHeader("Req-Client"),
ReqAppIntel: c.GetHeader("Req-App-Intel"),
ClientIP: resolveClientIP(c),
UserAgent: c.GetHeader("User-Agent"),
RawPayload: string(raw),
},
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/app-system/error-logs/agora")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
bannedOnly, _ := strconv.ParseBool(strings.TrimSpace(c.DefaultQuery("bannedOnly", "false")))
resp, err := service.PageAgoraErrorLogs(c.Request.Context(), errorlog.AgoraErrorLogQuery{
SysOrigin: c.Query("sysOrigin"),
UserID: parseInt64(c.Query("userId")),
RoomID: c.Query("roomId"),
AgoraUID: c.Query("agoraUid"),
ErrorCodeType: c.Query("errorCodeType"),
ReasonType: c.Query("reasonType"),
BannedOnly: bannedOnly,
NetworkType: c.Query("networkType"),
StartTime: c.Query("startTime"),
EndTime: c.Query("endTime"),
Cursor: cursor,
Limit: limit,
})
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -9,6 +9,7 @@ import (
"chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun" "chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameopen"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"chatapp3-golang/internal/service/hostcenter" "chatapp3-golang/internal/service/hostcenter"
@ -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)

View File

@ -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"))),
) )

View File

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

View File

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

View File

@ -0,0 +1,173 @@
package errorlog
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
// ReportAgoraError persists one client-reported Agora error log.
func (s *Service) ReportAgoraError(ctx context.Context, user AuthUser, req AgoraErrorReportRequest, meta AgoraErrorReportContext) (*AgoraErrorReportResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable")
}
roomID := req.RoomID.String()
if roomID == "" {
return nil, NewAppError(http.StatusBadRequest, "room_id_required", "roomId is required")
}
reasonType := firstNonBlank(req.ConnectionChangedReasonType, req.ReasonType, req.Reason)
bannedByServer := isBannedByServer(reasonType)
id, err := utils.NextID()
if err != nil {
return nil, err
}
now := time.Now()
row := model.AgoraErrorLog{
ID: id,
SysOrigin: normalizeSysOrigin(user.SysOrigin),
UserID: user.UserID,
RoomID: trimMax(roomID, 64),
AgoraUID: trimMax(req.AgoraUID.String(), 64),
ErrorCodeType: trimMax(req.ErrorCodeType, 128),
ConnectionChangedReasonType: trimMax(reasonType, 128),
BannedByServer: bannedByServer,
IsTimeout: boolValue(req.Timeout, req.IsTimeout),
TokenRequestSuccess: boolPtrValue(req.TokenRequestSuccess, req.TokenSuccess),
NetworkType: trimMax(req.NetworkType, 64),
ReqClient: trimMax(meta.ReqClient, 32),
ReqAppIntel: trimMax(meta.ReqAppIntel, 255),
ClientIP: trimMax(meta.ClientIP, 64),
UserAgent: trimMax(meta.UserAgent, 255),
RawPayload: strings.TrimSpace(meta.RawPayload),
CreateTime: now,
UpdateTime: now,
}
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
return &AgoraErrorReportResponse{
ID: strconv.FormatInt(id, 10),
BannedByServer: bannedByServer,
}, nil
}
// PageAgoraErrorLogs returns admin Agora error logs.
func (s *Service) PageAgoraErrorLogs(ctx context.Context, query AgoraErrorLogQuery) (*AgoraErrorLogPageResponse, error) {
if s == nil || s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "error_log_unavailable", "error log service is unavailable")
}
cursor, limit := normalizePage(query.Cursor, query.Limit)
startTime, endTime, err := parseAdminTimeRange(query.StartTime, query.EndTime)
if err != nil {
return nil, err
}
filter := query
filter.Cursor = cursor
filter.Limit = limit
filter.StartAt = startTime
filter.EndAt = endTime
base := s.agoraErrorLogQuery(ctx, filter)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.AgoraErrorLog
if err := s.agoraErrorLogQuery(ctx, filter).
Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
return &AgoraErrorLogPageResponse{
Records: agoraErrorLogItems(rows),
Total: total,
Current: cursor,
Size: limit,
}, nil
}
// AgoraErrorLogQuery is the admin filter for Agora error logs.
type AgoraErrorLogQuery struct {
SysOrigin string
UserID int64
RoomID string
AgoraUID string
ErrorCodeType string
ReasonType string
BannedOnly bool
NetworkType string
StartTime string
EndTime string
Cursor int
Limit int
StartAt time.Time
EndAt time.Time
}
func (s *Service) agoraErrorLogQuery(ctx context.Context, filter AgoraErrorLogQuery) *gorm.DB {
query := s.db.WithContext(ctx).Model(&model.AgoraErrorLog{}).
Where("create_time >= ? AND create_time <= ?", filter.StartAt, filter.EndAt)
if sysOrigin := normalizeSysOrigin(filter.SysOrigin); sysOrigin != "" {
query = query.Where("sys_origin = ?", sysOrigin)
}
if filter.UserID > 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if roomID := strings.TrimSpace(filter.RoomID); roomID != "" {
query = query.Where("room_id = ?", roomID)
}
if agoraUID := strings.TrimSpace(filter.AgoraUID); agoraUID != "" {
query = query.Where("agora_uid = ?", agoraUID)
}
if codeType := strings.TrimSpace(filter.ErrorCodeType); codeType != "" {
query = query.Where("error_code_type LIKE ?", "%"+codeType+"%")
}
if reasonType := strings.TrimSpace(filter.ReasonType); reasonType != "" {
query = query.Where("connection_changed_reason_type LIKE ?", "%"+reasonType+"%")
}
if filter.BannedOnly {
query = query.Where("banned_by_server = ?", true)
}
if networkType := strings.TrimSpace(filter.NetworkType); networkType != "" {
query = query.Where("network_type = ?", networkType)
}
return query
}
func agoraErrorLogItems(rows []model.AgoraErrorLog) []AgoraErrorLogItem {
items := make([]AgoraErrorLogItem, 0, len(rows))
for _, row := range rows {
items = append(items, AgoraErrorLogItem{
ID: strconv.FormatInt(row.ID, 10),
SysOrigin: row.SysOrigin,
UserID: strconv.FormatInt(row.UserID, 10),
RoomID: row.RoomID,
AgoraUID: row.AgoraUID,
ErrorCodeType: row.ErrorCodeType,
ConnectionChangedReasonType: row.ConnectionChangedReasonType,
BannedByServer: row.BannedByServer,
IsTimeout: row.IsTimeout,
TokenRequestSuccess: row.TokenRequestSuccess,
NetworkType: row.NetworkType,
ReqClient: row.ReqClient,
ReqAppIntel: row.ReqAppIntel,
ClientIP: row.ClientIP,
UserAgent: row.UserAgent,
RawPayload: row.RawPayload,
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
})
}
return items
}

View File

@ -0,0 +1,84 @@
package errorlog
import (
"context"
"testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestReportAgoraErrorDetectsBannedByServer(t *testing.T) {
service := newTestService(t)
tokenOK := false
timeout := true
resp, err := service.ReportAgoraError(context.Background(), common.AuthUser{
UserID: 1234567,
SysOrigin: "LIKEI",
}, AgoraErrorReportRequest{
RoomID: StringValue("9001"),
AgoraUID: StringValue("8888"),
ErrorCodeType: "ERR_LEAVE_CHANNEL_REJECTED",
ConnectionChangedReasonType: "BannedByServer(3)",
Timeout: &timeout,
TokenRequestSuccess: &tokenOK,
NetworkType: "wifi",
}, AgoraErrorReportContext{
ReqClient: "Android",
ReqAppIntel: "version=1.0.0;build=100",
ClientIP: "127.0.0.1",
RawPayload: `{"roomId":"9001","connectionChangedReasonType":"BannedByServer(3)"}`,
})
if err != nil {
t.Fatalf("ReportAgoraError returned error: %v", err)
}
if !resp.BannedByServer {
t.Fatalf("BannedByServer = false, want true")
}
page, err := service.PageAgoraErrorLogs(context.Background(), AgoraErrorLogQuery{
SysOrigin: "LIKEI",
BannedOnly: true,
Cursor: 1,
Limit: 20,
})
if err != nil {
t.Fatalf("PageAgoraErrorLogs returned error: %v", err)
}
if page.Total != 1 || len(page.Records) != 1 {
t.Fatalf("page total=%d records=%d, want 1", page.Total, len(page.Records))
}
record := page.Records[0]
if record.RoomID != "9001" || record.AgoraUID != "8888" || !record.IsTimeout {
t.Fatalf("unexpected record: %+v", record)
}
if record.TokenRequestSuccess == nil || *record.TokenRequestSuccess {
t.Fatalf("TokenRequestSuccess = %v, want false", record.TokenRequestSuccess)
}
}
func TestStringValueAcceptsNumericJSON(t *testing.T) {
var value StringValue
if err := value.UnmarshalJSON([]byte(`1838887943867809794`)); err != nil {
t.Fatalf("UnmarshalJSON returned error: %v", err)
}
if value.String() != "1838887943867809794" {
t.Fatalf("value = %q, want numeric string", value.String())
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.AgoraErrorLog{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewService(db)
}

View File

@ -0,0 +1,263 @@
package errorlog
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"gorm.io/gorm"
)
const adminTimeLayout = "2006-01-02 15:04:05"
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
// Service handles app error logs.
type Service struct {
db dbHandle
}
// NewService creates an app error log service.
func NewService(db dbHandle) *Service {
return &Service{db: db}
}
// StringValue accepts JSON string or number and stores it as a string.
type StringValue string
func (v StringValue) String() string {
return strings.TrimSpace(string(v))
}
// UnmarshalJSON accepts quoted strings and numeric IDs from clients.
func (v *StringValue) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = ""
return nil
}
if strings.HasPrefix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
*v = StringValue(strings.TrimSpace(unquoted))
return nil
}
*v = StringValue(raw)
return nil
}
// AgoraErrorReportRequest is the client report payload for Agora errors.
type AgoraErrorReportRequest struct {
RoomID StringValue `json:"roomId"`
AgoraUID StringValue `json:"agoraUid"`
ErrorCodeType string `json:"errorCodeType"`
ConnectionChangedReasonType string `json:"connectionChangedReasonType"`
ReasonType string `json:"reasonType"`
Reason string `json:"reason"`
Timeout *bool `json:"timeout"`
IsTimeout *bool `json:"isTimeout"`
TokenRequestSuccess *bool `json:"tokenRequestSuccess"`
TokenSuccess *bool `json:"tokenSuccess"`
NetworkType string `json:"networkType"`
}
// AgoraErrorReportContext carries request metadata captured at the router layer.
type AgoraErrorReportContext struct {
ReqClient string
ReqAppIntel string
ClientIP string
UserAgent string
RawPayload string
}
// AgoraErrorReportResponse is returned after a report is persisted.
type AgoraErrorReportResponse struct {
ID string `json:"id"`
BannedByServer bool `json:"bannedByServer"`
}
// AgoraErrorLogPageResponse is the admin page response.
type AgoraErrorLogPageResponse struct {
Records []AgoraErrorLogItem `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
// AgoraErrorLogItem is an admin-readable Agora error log.
type AgoraErrorLogItem struct {
ID string `json:"id"`
SysOrigin string `json:"sysOrigin"`
UserID string `json:"userId"`
RoomID string `json:"roomId"`
AgoraUID string `json:"agoraUid"`
ErrorCodeType string `json:"errorCodeType"`
ConnectionChangedReasonType string `json:"connectionChangedReasonType"`
BannedByServer bool `json:"bannedByServer"`
IsTimeout bool `json:"isTimeout"`
TokenRequestSuccess *bool `json:"tokenRequestSuccess"`
NetworkType string `json:"networkType"`
ReqClient string `json:"reqClient"`
ReqAppIntel string `json:"reqAppIntel"`
ClientIP string `json:"clientIp"`
UserAgent string `json:"userAgent"`
RawPayload string `json:"rawPayload"`
CreateTime string `json:"createTime"`
UpdateTime string `json:"updateTime"`
}
func normalizePage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
func normalizeSysOrigin(sysOrigin string) string {
return strings.ToUpper(strings.TrimSpace(sysOrigin))
}
func trimMax(value string, max int) string {
value = strings.TrimSpace(value)
if max > 0 && len(value) > max {
return value[:max]
}
return value
}
func boolValue(primary *bool, aliases ...*bool) bool {
if primary != nil {
return *primary
}
for _, alias := range aliases {
if alias != nil {
return *alias
}
}
return false
}
func boolPtrValue(primary *bool, aliases ...*bool) *bool {
if primary != nil {
return primary
}
for _, alias := range aliases {
if alias != nil {
return alias
}
}
return nil
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func isBannedByServer(reason string) bool {
normalized := strings.ToLower(strings.TrimSpace(reason))
if normalized == "" {
return false
}
return normalized == "3" ||
strings.Contains(normalized, "bannedbyserver") ||
strings.Contains(normalized, "banned_by_server") ||
strings.Contains(normalized, "banned by server")
}
func formatDateTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(adminTimeLayout)
}
func parseAdminTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) {
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
now := time.Now()
if startRaw == "" && endRaw == "" {
return now.AddDate(0, 0, -7), now, nil
}
var start time.Time
var end time.Time
var err error
if startRaw != "" {
start, err = parseAdminTime(startRaw, false)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if endRaw != "" {
end, err = parseAdminTime(endRaw, true)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if startRaw == "" {
start = end.AddDate(0, 0, -7)
}
if endRaw == "" {
end = start.AddDate(0, 0, 7)
}
if end.Before(start) {
return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime")
}
return start, end, nil
}
func parseAdminTime(raw string, endOfDay bool) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, nil
}
if millis, err := strconv.ParseInt(raw, 10, 64); err == nil {
return time.UnixMilli(millis), nil
}
for _, layout := range []string{
adminTimeLayout,
time.RFC3339,
"2006-01-02T15:04:05.000Z07:00",
"2006-01-02",
} {
var parsed time.Time
var err error
if strings.Contains(layout, "Z07:00") || layout == time.RFC3339 {
parsed, err = time.Parse(layout, raw)
} else {
parsed, err = time.ParseInLocation(layout, raw, time.Local)
}
if err == nil {
if layout == "2006-01-02" && endOfDay {
parsed = parsed.Add(24*time.Hour - time.Nanosecond)
}
return parsed, nil
}
}
return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS `agora_error_log` (
`id` BIGINT NOT NULL COMMENT '主键ID',
`sys_origin` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '系统来源',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '上报用户ID',
`room_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '房间ID',
`agora_uid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Agora UID',
`error_code_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ErrorCodeType',
`connection_changed_reason_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Agora ConnectionChangedReasonType',
`banned_by_server` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 BannedByServer/kicking-rule',
`is_timeout` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超时',
`token_request_success` TINYINT(1) DEFAULT NULL COMMENT 'token 请求是否成功',
`network_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '网络类型',
`req_client` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '请求客户端',
`req_app_intel` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'App版本信息请求头',
`client_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP',
`user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'User-Agent',
`raw_payload` MEDIUMTEXT COMMENT '原始上报内容',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_agora_error_sys_time` (`sys_origin`, `create_time`),
KEY `idx_agora_error_user_time` (`user_id`, `create_time`),
KEY `idx_agora_error_room_time` (`room_id`, `create_time`),
KEY `idx_agora_error_code_time` (`error_code_type`, `create_time`),
KEY `idx_agora_error_reason` (`connection_changed_reason_type`),
KEY `idx_agora_error_banned_time` (`banned_by_server`, `create_time`),
KEY `idx_agora_error_network` (`network_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agora错误日志';

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