feat: Add player_configs feature and migrate user preferences

- Implemented player_configs table to store multiple player configurations per user.
- Migrated existing player settings from user_preferences to player_configs.
- Removed player-related columns from user_preferences.
- Added referral state fields to user for tracking referral rewards.
- Created migration scripts for database changes and data migration.
- Added test cases for app services and usage helpers.
- Introduced video job service interfaces and implementations.
This commit is contained in:
2026-03-24 16:08:36 +00:00
parent 91e5e3542b
commit e7fdd0e1ab
103 changed files with 9540 additions and 8446 deletions

View File

@@ -1,219 +0,0 @@
//go:build ignore
// +build ignore
package app
import (
"context"
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"stream.api/internal/api/admin"
"stream.api/internal/api/adtemplates"
"stream.api/internal/api/auth"
"stream.api/internal/api/domains"
"stream.api/internal/api/notifications"
"stream.api/internal/api/payment"
"stream.api/internal/api/plan"
"stream.api/internal/api/preferences"
"stream.api/internal/api/usage"
"stream.api/internal/api/video"
"stream.api/internal/config"
"stream.api/internal/middleware"
videoruntime "stream.api/internal/video/runtime"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
func SetupRouter(cfg *config.Config, db *gorm.DB, c cache.Cache, t token.Provider, l logger.Logger) (*gin.Engine, *videoruntime.Module, error) {
if cfg.Server.Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
// Global Middleware
r.Use(gin.Logger())
r.Use(middleware.Recovery()) // Custom Recovery with JSON response
r.Use(middleware.ErrorHandler()) // Handle c.Errors
// CORS Middleware
r.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORS.AllowOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Authorization", "Content-Type"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
// Only enable Swagger in non-release mode
if cfg.Server.Mode != "release" {
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// Global Middleware (Logger, Recovery are default)
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "up",
})
})
// Auth Handler
authHandler := auth.NewHandler(c, t, l, cfg, db)
// api := r.Group("/v")
authGroup := r.Group("/auth")
{
authGroup.POST("/login", authHandler.Login)
authGroup.POST("/register", authHandler.Register)
authGroup.POST("/forgot-password", authHandler.ForgotPassword)
authGroup.POST("/reset-password", authHandler.ResetPassword)
authGroup.GET("/google/login", authHandler.LoginGoogle)
authGroup.GET("/google/callback", authHandler.GoogleCallback)
}
// Auth Middleware
authMiddleware := middleware.NewAuthMiddleware(c, t, cfg, db, l)
// Init Storage Provider (S3)
s3Provider, err := storage.NewS3Provider(cfg)
if err != nil {
l.Error("Failed to initialize S3 provider", "error", err)
// We might want to panic or continue with warning depending on criticality.
// For now, let's log and proceed, but video uploads will fail.
}
// Handlers
planHandler := plan.NewHandler(l, cfg, db)
paymentHandler := payment.NewHandler(l, cfg, db)
usageHandler := usage.NewHandler(l, db)
videoHandler := video.NewHandler(l, cfg, db, s3Provider)
preferencesHandler := preferences.NewHandler(l, db)
notificationHandler := notifications.NewHandler(l, db)
domainHandler := domains.NewHandler(l, db)
adTemplateHandler := adtemplates.NewHandler(l, db)
// Example protected group
protected := r.Group("")
protected.Use(authMiddleware.Handle())
{
protected.GET("/me", authHandler.GetMe)
protected.PUT("/me", authHandler.UpdateMe)
protected.DELETE("/me", authHandler.DeleteMe)
protected.POST("/me/clear-data", authHandler.ClearMyData)
protected.POST("/auth/logout", authHandler.Logout)
protected.POST("/auth/change-password", authHandler.ChangePassword)
preferences := protected.Group("/settings/preferences")
preferences.GET("", preferencesHandler.GetPreferences)
preferences.PUT("", preferencesHandler.UpdatePreferences)
notifications := protected.Group("/notifications")
notifications.GET("", notificationHandler.ListNotifications)
notifications.POST("/:id/read", notificationHandler.MarkRead)
notifications.POST("/read-all", notificationHandler.MarkAllRead)
notifications.DELETE("/:id", notificationHandler.DeleteNotification)
notifications.DELETE("", notificationHandler.ClearNotifications)
domains := protected.Group("/domains")
domains.GET("", domainHandler.ListDomains)
domains.POST("", domainHandler.CreateDomain)
domains.DELETE("/:id", domainHandler.DeleteDomain)
adTemplates := protected.Group("/ad-templates")
adTemplates.GET("", adTemplateHandler.ListTemplates)
adTemplates.POST("", adTemplateHandler.CreateTemplate)
adTemplates.PUT("/:id", adTemplateHandler.UpdateTemplate)
adTemplates.DELETE("/:id", adTemplateHandler.DeleteTemplate)
// Plans
plans := protected.Group("/plans")
plans.GET("", planHandler.ListPlans)
// Payments
payments := protected.Group("/payments")
payments.POST("", paymentHandler.CreatePayment)
payments.GET("/history", paymentHandler.ListPaymentHistory)
payments.GET("/:id/invoice", paymentHandler.DownloadInvoice)
wallet := protected.Group("/wallet")
wallet.POST("/topups", paymentHandler.TopupWallet)
protected.GET("/usage", usageHandler.GetUsage)
// Videos
video := protected.Group("/videos")
video.POST("/upload-url", videoHandler.GetUploadURL)
video.POST("", videoHandler.CreateVideo)
video.GET("", videoHandler.ListVideos)
video.GET("/:id", videoHandler.GetVideo)
video.PUT("/:id", videoHandler.UpdateVideo)
video.DELETE("/:id", videoHandler.DeleteVideo)
}
renderModule, err := videoruntime.NewModule(context.Background(), cfg, db, c, t, l)
if err != nil {
return nil, nil, err
}
r.Use(videoruntime.MetricsMiddleware())
r.GET("/health/live", renderModule.HandleLive)
r.GET("/health/ready", renderModule.HandleReady)
r.GET("/health/detailed", renderModule.HandleDetailed)
r.GET("/metrics", renderModule.MetricsHandler())
// Admin routes — require auth + admin role
adminHandler := admin.NewHandler(l, db, renderModule)
adminGroup := r.Group("/admin")
adminGroup.Use(authMiddleware.Handle())
adminGroup.Use(middleware.RequireAdmin())
{
adminGroup.GET("/dashboard", adminHandler.Dashboard)
adminGroup.GET("/users", adminHandler.ListUsers)
adminGroup.POST("/users", adminHandler.CreateUser)
adminGroup.GET("/users/:id", adminHandler.GetUser)
adminGroup.PUT("/users/:id", adminHandler.UpdateUser)
adminGroup.PUT("/users/:id/role", adminHandler.UpdateUserRole)
adminGroup.DELETE("/users/:id", adminHandler.DeleteUser)
adminGroup.GET("/videos", adminHandler.ListVideos)
adminGroup.POST("/videos", adminHandler.CreateVideo)
adminGroup.GET("/videos/:id", adminHandler.GetVideo)
adminGroup.PUT("/videos/:id", adminHandler.UpdateVideo)
adminGroup.DELETE("/videos/:id", adminHandler.DeleteVideo)
adminGroup.GET("/payments", adminHandler.ListPayments)
adminGroup.POST("/payments", adminHandler.CreatePayment)
adminGroup.GET("/payments/:id", adminHandler.GetPayment)
adminGroup.PUT("/payments/:id", adminHandler.UpdatePayment)
adminGroup.GET("/plans", adminHandler.ListPlans)
adminGroup.POST("/plans", adminHandler.CreatePlan)
adminGroup.PUT("/plans/:id", adminHandler.UpdatePlan)
adminGroup.DELETE("/plans/:id", adminHandler.DeletePlan)
adminGroup.GET("/ad-templates", adminHandler.ListAdTemplates)
adminGroup.POST("/ad-templates", adminHandler.CreateAdTemplate)
adminGroup.GET("/ad-templates/:id", adminHandler.GetAdTemplate)
adminGroup.PUT("/ad-templates/:id", adminHandler.UpdateAdTemplate)
adminGroup.DELETE("/ad-templates/:id", adminHandler.DeleteAdTemplate)
adminGroup.GET("/jobs", adminHandler.ListJobs)
adminGroup.POST("/jobs", adminHandler.CreateJob)
adminGroup.GET("/jobs/:id", adminHandler.GetJob)
adminGroup.GET("/jobs/:id/logs", adminHandler.GetJobLogs)
adminGroup.POST("/jobs/:id/cancel", adminHandler.CancelJob)
adminGroup.POST("/jobs/:id/retry", adminHandler.RetryJob)
adminGroup.GET("/agents", adminHandler.ListAgents)
adminGroup.POST("/agents/:id/restart", adminHandler.RestartAgent)
adminGroup.POST("/agents/:id/update", adminHandler.UpdateAgent)
}
return r, renderModule, nil
}

76
internal/app/grpc.go Normal file
View File

@@ -0,0 +1,76 @@
package app
import (
"context"
"net"
grpcpkg "google.golang.org/grpc"
"gorm.io/gorm"
"stream.api/internal/config"
apprpc "stream.api/internal/rpc/app"
"stream.api/internal/video"
runtime "stream.api/internal/video/runtime"
redisadapter "stream.api/internal/video/runtime/adapters/queue/redis"
runtimegrpc "stream.api/internal/video/runtime/grpc"
"stream.api/internal/video/runtime/services"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/token"
)
type GRPCModule struct {
jobService *services.JobService
healthService *services.HealthService
agentRuntime *runtimegrpc.Server
mqttPublisher *runtime.MQTTBootstrap
grpcServer *grpcpkg.Server
cfg *config.Config
}
func NewGRPCModule(ctx context.Context, cfg *config.Config, db *gorm.DB, cacheClient cache.Cache, tokenProvider token.Provider, appLogger logger.Logger) (*GRPCModule, error) {
adapter, err := redisadapter.NewAdapter(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
if err != nil {
return nil, err
}
jobService := services.NewJobService(adapter, adapter)
healthService := services.NewHealthService(db, adapter.Client(), cfg.Render.ServiceName)
agentRuntime := runtimegrpc.NewServer(jobService, cfg.Render.AgentSecret)
videoService := video.NewService(db, jobService)
grpcServer := grpcpkg.NewServer()
module := &GRPCModule{
jobService: jobService,
healthService: healthService,
agentRuntime: agentRuntime,
grpcServer: grpcServer,
cfg: cfg,
}
if publisher, err := runtime.NewMQTTBootstrap(jobService, agentRuntime, appLogger); err != nil {
appLogger.Error("Failed to initialize MQTT publisher", "error", err)
} else {
module.mqttPublisher = publisher
agentRuntime.SetAgentEventHandler(func(eventType string, agent *services.AgentWithStats) {
runtime.PublishAgentMQTTEvent(publisher.Client(), appLogger, eventType, agent)
})
}
agentRuntime.Register(grpcServer)
apprpc.Register(grpcServer, apprpc.NewServices(cacheClient, tokenProvider, db, appLogger, cfg, videoService, agentRuntime))
if module.mqttPublisher != nil {
module.mqttPublisher.Start(ctx)
}
return module, nil
}
func (m *GRPCModule) JobService() *services.JobService { return m.jobService }
func (m *GRPCModule) AgentRuntime() *runtimegrpc.Server { return m.agentRuntime }
func (m *GRPCModule) GRPCServer() *grpcpkg.Server { return m.grpcServer }
func (m *GRPCModule) GRPCAddress() string { return ":" + m.cfg.Server.GRPCPort }
func (m *GRPCModule) ServeGRPC(listener net.Listener) error { return m.grpcServer.Serve(listener) }
func (m *GRPCModule) Shutdown() {
if m.grpcServer != nil {
m.grpcServer.GracefulStop()
}
}