diff --git a/backend/cmd/worker/main.go b/backend/cmd/worker/main.go index f67875c..60e0975 100644 --- a/backend/cmd/worker/main.go +++ b/backend/cmd/worker/main.go @@ -1,316 +1,316 @@ -package main - -import ( - "context" - "feedsystem_video_go/internal/config" - "feedsystem_video_go/internal/db" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/observability" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "feedsystem_video_go/internal/worker" - mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq" - "log" - "os" - "os/signal" - "strconv" - "syscall" - "time" - - amqp "github.com/rabbitmq/amqp091-go" - "gorm.io/gorm" -) - -const ( - socialExchange = "social.events" - socialQueue = "social.events" - socialBindingKey = "social.*" - - likeExchange = "like.events" - likeQueue = "like.events" - likeBindingKey = "like.*" - - commentExchange = "comment.events" - commentQueue = "comment.events" - commentBindingKey = "comment.*" - - popularityExchange = "video.popularity.events" - popularityQueue = "video.popularity.events" - popularityBindingKey = "video.popularity.*" -) - -func connectWithRetry(name string, maxRetries int, fn func() error) { - for i := 0; i < maxRetries; i++ { - if err := fn(); err == nil { - return - } - wait := time.Duration(1< 30*time.Second { - wait = 30 * time.Second - } - log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries) - time.Sleep(wait) - } - log.Fatalf("%s: 超过最大重试次数", name) -} - -func main() { - // 加载配置 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = "configs/config.yaml" - } - log.Printf("Loading config from %s", configPath) - cfg, usedDefault, err := config.LoadLocalDev(configPath) - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - if usedDefault { - log.Printf("Config File %s not found, using default local config", configPath) - } else { - log.Printf("Config loaded from file: %s", configPath) - } - // 连接数据库(带重试) - var sqlDB *gorm.DB - connectWithRetry("MySQL", 10, func() error { - var err error - sqlDB, err = db.NewDB(cfg.Database) - return err - }) - defer db.CloseDB(sqlDB) - - // 连接 Redis(用于流行度更新) - cache, err := rediscache.NewFromEnv(&cfg.Redis) - if err != nil { - log.Printf("Redis config error (popularity worker disabled): %v", err) - cache = nil - } else { - pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - if err := cache.Ping(pingCtx); err != nil { - log.Printf("Redis not available (popularity worker disabled): %v", err) - _ = cache.Close() - cache = nil - } else { - defer cache.Close() - log.Printf("Redis connected (popularity worker enabled)") - } - } - // 连接 RabbitMQ(带重试) - url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/" - var conn *amqp.Connection - connectWithRetry("RabbitMQ", 10, func() error { - var err error - conn, err = amqp.Dial(url) - return err - }) - defer conn.Close() - // 创建 RabbitMQ 通道 - ch, err := conn.Channel() - if err != nil { - log.Fatalf("Failed to open rabbitmq channel: %v", err) - } - defer ch.Close() - // 声明 Social 交换机和队列 - if err := declareSocialTopology(ch); err != nil { - log.Fatalf("Failed to declare social topology: %v", err) - } - if err := declareLikeTopology(ch); err != nil { - log.Fatalf("Failed to declare like topology: %v", err) - } - if err := declareCommentTopology(ch); err != nil { - log.Fatalf("Failed to declare comment topology: %v", err) - } - if cache != nil { - if err := declarePopularityTopology(ch); err != nil { - log.Fatalf("Failed to declare popularity topology: %v", err) - } - } - if err := ch.Qos(50, 0, false); err != nil { - log.Fatalf("Failed to set qos: %v", err) - } - - repo := social.NewSocialRepository(sqlDB) - socialWorker := worker.NewSocialWorker(ch, repo, socialQueue) - videoRepo := video.NewVideoRepository(sqlDB) - likeRepo := video.NewLikeRepository(sqlDB) - commentRepo := video.NewCommentRepository(sqlDB) - likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue) - commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue) - var popularityWorker *worker.PopularityWorker - if cache != nil { - popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue) - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - pprofServer, err := observability.NewPprofServer( - "Worker", - cfg.ObservabilityConfig.Pprof.Enabled, - cfg.ObservabilityConfig.Pprof.WorkerAddr, - ) - if err != nil { - log.Printf("Failed to start worker pprof server: %v", err) - } - if pprofServer != nil { - defer pprofServer.Close() - } - - errCh := make(chan error, 4) - log.Printf("Worker started, consuming queue=%s", socialQueue) - go func() { errCh <- socialWorker.Run(ctx) }() - log.Printf("Worker started, consuming queue=%s", likeQueue) - go func() { errCh <- likeWorker.Run(ctx) }() - log.Printf("Worker started, consuming queue=%s", commentQueue) - go func() { errCh <- commentWorker.Run(ctx) }() - if popularityWorker != nil { - log.Printf("Worker started, consuming queue=%s", popularityQueue) - go func() { errCh <- popularityWorker.Run(ctx) }() - } - - err = <-errCh - if err != nil && err != context.Canceled { - log.Fatalf("Worker stopped: %v", err) - } - log.Printf("Worker stopped") -} - -func declareSocialTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - socialExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - socialQueue, - true, - false, - false, - false, - amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, - ) - if err != nil { - return err - } - - if err := ch.QueueBind( - q.Name, - socialBindingKey, - socialExchange, - false, - nil, - ); err != nil { - return err - } - return nil -} - -func declarePopularityTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - popularityExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - popularityQueue, - true, - false, - false, - false, - amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - popularityBindingKey, - popularityExchange, - false, - nil, - ) -} - -func declareLikeTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - likeExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - likeQueue, - true, - false, - false, - false, - amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - likeBindingKey, - likeExchange, - false, - nil, - ) -} - -func declareCommentTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - commentExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - commentQueue, - true, - false, - false, - false, - amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - commentBindingKey, - commentExchange, - false, - nil, - ) -} +package main + +import ( + "context" + "feedsystem_video_go/internal/config" + "feedsystem_video_go/internal/db" + mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/observability" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "feedsystem_video_go/internal/worker" + "log" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + amqp "github.com/rabbitmq/amqp091-go" + "gorm.io/gorm" +) + +const ( + socialExchange = "social.events" + socialQueue = "social.events" + socialBindingKey = "social.*" + + likeExchange = "like.events" + likeQueue = "like.events" + likeBindingKey = "like.*" + + commentExchange = "comment.events" + commentQueue = "comment.events" + commentBindingKey = "comment.*" + + popularityExchange = "video.popularity.events" + popularityQueue = "video.popularity.events" + popularityBindingKey = "video.popularity.*" +) + +func connectWithRetry(name string, maxRetries int, fn func() error) { + for i := 0; i < maxRetries; i++ { + if err := fn(); err == nil { + return + } + wait := time.Duration(1< 30*time.Second { + wait = 30 * time.Second + } + log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries) + time.Sleep(wait) + } + log.Fatalf("%s: 超过最大重试次数", name) +} + +func main() { + // 加载配置 + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "configs/config.yaml" + } + log.Printf("Loading config from %s", configPath) + cfg, usedDefault, err := config.LoadLocalDev(configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + if usedDefault { + log.Printf("Config File %s not found, using default local config", configPath) + } else { + log.Printf("Config loaded from file: %s", configPath) + } + // 连接数据库(带重试) + var sqlDB *gorm.DB + connectWithRetry("MySQL", 10, func() error { + var err error + sqlDB, err = db.NewDB(cfg.Database) + return err + }) + defer db.CloseDB(sqlDB) + + // 连接 Redis(用于流行度更新) + cache, err := rediscache.NewFromEnv(&cfg.Redis) + if err != nil { + log.Printf("Redis config error (popularity worker disabled): %v", err) + cache = nil + } else { + pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + if err := cache.Ping(pingCtx); err != nil { + log.Printf("Redis not available (popularity worker disabled): %v", err) + _ = cache.Close() + cache = nil + } else { + defer cache.Close() + log.Printf("Redis connected (popularity worker enabled)") + } + } + // 连接 RabbitMQ(带重试) + url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/" + var conn *amqp.Connection + connectWithRetry("RabbitMQ", 10, func() error { + var err error + conn, err = amqp.Dial(url) + return err + }) + defer conn.Close() + // 创建 RabbitMQ 通道 + ch, err := conn.Channel() + if err != nil { + log.Fatalf("Failed to open rabbitmq channel: %v", err) + } + defer ch.Close() + // 声明 Social 交换机和队列 + if err := declareSocialTopology(ch); err != nil { + log.Fatalf("Failed to declare social topology: %v", err) + } + if err := declareLikeTopology(ch); err != nil { + log.Fatalf("Failed to declare like topology: %v", err) + } + if err := declareCommentTopology(ch); err != nil { + log.Fatalf("Failed to declare comment topology: %v", err) + } + if cache != nil { + if err := declarePopularityTopology(ch); err != nil { + log.Fatalf("Failed to declare popularity topology: %v", err) + } + } + if err := ch.Qos(50, 0, false); err != nil { + log.Fatalf("Failed to set qos: %v", err) + } + + repo := social.NewSocialRepository(sqlDB) + socialWorker := worker.NewSocialWorker(ch, repo, socialQueue) + videoRepo := video.NewVideoRepository(sqlDB) + likeRepo := video.NewLikeRepository(sqlDB) + commentRepo := video.NewCommentRepository(sqlDB) + likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue) + commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue) + var popularityWorker *worker.PopularityWorker + if cache != nil { + popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pprofServer, err := observability.NewPprofServer( + "Worker", + cfg.ObservabilityConfig.Pprof.Enabled, + cfg.ObservabilityConfig.Pprof.WorkerAddr, + ) + if err != nil { + log.Printf("Failed to start worker pprof server: %v", err) + } + if pprofServer != nil { + defer pprofServer.Close() + } + + errCh := make(chan error, 4) + log.Printf("Worker started, consuming queue=%s", socialQueue) + go func() { errCh <- socialWorker.Run(ctx) }() + log.Printf("Worker started, consuming queue=%s", likeQueue) + go func() { errCh <- likeWorker.Run(ctx) }() + log.Printf("Worker started, consuming queue=%s", commentQueue) + go func() { errCh <- commentWorker.Run(ctx) }() + if popularityWorker != nil { + log.Printf("Worker started, consuming queue=%s", popularityQueue) + go func() { errCh <- popularityWorker.Run(ctx) }() + } + + err = <-errCh + if err != nil && err != context.Canceled { + log.Fatalf("Worker stopped: %v", err) + } + log.Printf("Worker stopped") +} + +func declareSocialTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + socialExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + socialQueue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, + ) + if err != nil { + return err + } + + if err := ch.QueueBind( + q.Name, + socialBindingKey, + socialExchange, + false, + nil, + ); err != nil { + return err + } + return nil +} + +func declarePopularityTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + popularityExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + popularityQueue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + popularityBindingKey, + popularityExchange, + false, + nil, + ) +} + +func declareLikeTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + likeExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + likeQueue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + likeBindingKey, + likeExchange, + false, + nil, + ) +} + +func declareCommentTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + commentExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + commentQueue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange}, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + commentBindingKey, + commentExchange, + false, + nil, + ) +} diff --git a/backend/internal/account/entity.go b/backend/internal/account/entity.go index a9f61e7..ff46a56 100644 --- a/backend/internal/account/entity.go +++ b/backend/internal/account/entity.go @@ -1,79 +1,79 @@ -package account - -type Account struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"unique" json:"username"` - Password string `json:"-"` - Token string `json:"-"` - RefreshToken string `json:"-"` - AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"` - Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"` -} - -type CreateAccountRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type RenameRequest struct { - NewUsername string `json:"new_username"` -} - -type FindByIDRequest struct { - ID uint `json:"id"` -} - -type FindByIDResponse struct { - ID uint `json:"id"` - Username string `json:"username"` - AvatarURL string `json:"avatar_url,omitempty"` - Bio string `json:"bio,omitempty"` -} - -type FindByUsernameRequest struct { - Username string `json:"username"` -} - -type FindByUsernameResponse struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type ChangePasswordRequest struct { - Username string `json:"username"` - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` -} - -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type LoginResponse struct { - Token string `json:"token"` - RefreshToken string `json:"refresh_token"` - AccountID uint `json:"account_id"` - Username string `json:"username"` -} - -type UpdateProfileRequest struct { - AvatarURL string `json:"avatar_url"` - Bio string `json:"bio"` -} - -type RefreshRequest struct { - RefreshToken string `json:"refresh_token"` -} - -type GetProfileRequest struct { - AccountID uint `json:"account_id"` -} - -type GetProfileResponse struct { - Account FindByIDResponse `json:"account"` - VideoCount int64 `json:"video_count"` - TotalLikes int64 `json:"total_likes"` - FollowerCount int64 `json:"follower_count"` - VloggerCount int64 `json:"vlogger_count"` -} +package account + +type Account struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"unique" json:"username"` + Password string `json:"-"` + Token string `json:"-"` + RefreshToken string `json:"-"` + AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"` + Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"` +} + +type CreateAccountRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RenameRequest struct { + NewUsername string `json:"new_username"` +} + +type FindByIDRequest struct { + ID uint `json:"id"` +} + +type FindByIDResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url,omitempty"` + Bio string `json:"bio,omitempty"` +} + +type FindByUsernameRequest struct { + Username string `json:"username"` +} + +type FindByUsernameResponse struct { + ID uint `json:"id"` + Username string `json:"username"` +} + +type ChangePasswordRequest struct { + Username string `json:"username"` + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + AccountID uint `json:"account_id"` + Username string `json:"username"` +} + +type UpdateProfileRequest struct { + AvatarURL string `json:"avatar_url"` + Bio string `json:"bio"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type GetProfileRequest struct { + AccountID uint `json:"account_id"` +} + +type GetProfileResponse struct { + Account FindByIDResponse `json:"account"` + VideoCount int64 `json:"video_count"` + TotalLikes int64 `json:"total_likes"` + FollowerCount int64 `json:"follower_count"` + VloggerCount int64 `json:"vlogger_count"` +} diff --git a/backend/internal/account/handler.go b/backend/internal/account/handler.go index ba00a07..273ea51 100644 --- a/backend/internal/account/handler.go +++ b/backend/internal/account/handler.go @@ -1,257 +1,257 @@ -package account - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "net/http" - "os" - "path" - "path/filepath" - "strconv" - "strings" - - "feedsystem_video_go/internal/apierror" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -type AccountHandler struct { - accountService *AccountService -} - -func NewAccountHandler(accountService *AccountService) *AccountHandler { - return &AccountHandler{accountService: accountService} -} -func (h *AccountHandler) CreateAccount(c *gin.Context) { - var req CreateAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := h.accountService.CreateAccount(c.Request.Context(), &Account{ - Username: req.Username, - Password: req.Password, - }); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "account created"}) -} - -func (h *AccountHandler) Rename(c *gin.Context) { - var req RenameRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - accountID, err := getAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername) - if err != nil { - if errors.Is(err, ErrNewUsernameRequired) { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if errors.Is(err, ErrUsernameTaken) { - c.JSON(409, gin.H{"error": err.Error()}) - return - } - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(404, gin.H{"error": "account not found"}) - return - } - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"token": token}) -} - -func (h *AccountHandler) ChangePassword(c *gin.Context) { - var req ChangePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil { - c.JSON(400, gin.H{"error": "unsuccessfully password changed"}) - return - } - c.JSON(200, gin.H{"message": "successfully password changed"}) -} - -func (h *AccountHandler) FindByID(c *gin.Context) { - var req FindByIDRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, account) - } -} - -func (h *AccountHandler) FindByUsername(c *gin.Context) { - var req FindByUsernameRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, account) - } -} - -func (h *AccountHandler) Login(c *gin.Context) { - var req LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username}) -} - -func (h *AccountHandler) Logout(c *gin.Context) { - accountID, err := getAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "account logged out"}) -} - -func (h *AccountHandler) UploadAvatar(c *gin.Context) { - accountID, err := getAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - f, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) - return - } - const maxSize = 10 << 20 - if f.Size <= 0 || f.Size > maxSize { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) - return - } - ext := strings.ToLower(filepath.Ext(f.Filename)) - switch ext { - case ".jpg", ".jpeg", ".png", ".webp": - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"}) - return - } - dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10)) - if err := os.MkdirAll(dir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - filename, err := randHex(16) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - filename = filename + ext - absPath := filepath.Join(dir, filename) - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename) - avatarURL := buildAbsoluteURL(c, urlPath) - if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL}) -} - -func (h *AccountHandler) UpdateProfile(c *gin.Context) { - accountID, err := getAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - var req UpdateProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) -} - -func (h *AccountHandler) Refresh(c *gin.Context) { - var req RefreshRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) - return - } - c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username}) -} - -func randHex(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("rand.Read: %w", err) - } - return hex.EncodeToString(b), nil -} - -func buildAbsoluteURL(c *gin.Context, p string) string { - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { - scheme = xf - } - return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) -} - -func getAccountID(c *gin.Context) (uint, error) { - value, exists := c.Get("accountID") - if !exists { - return 0, errors.New("accountID not found") - } - id, ok := value.(uint) - if !ok { - return 0, errors.New("accountID has invalid type") - } - return id, nil -} +package account + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "feedsystem_video_go/internal/apierror" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AccountHandler struct { + accountService *AccountService +} + +func NewAccountHandler(accountService *AccountService) *AccountHandler { + return &AccountHandler{accountService: accountService} +} +func (h *AccountHandler) CreateAccount(c *gin.Context) { + var req CreateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.CreateAccount(c.Request.Context(), &Account{ + Username: req.Username, + Password: req.Password, + }); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "account created"}) +} + +func (h *AccountHandler) Rename(c *gin.Context) { + var req RenameRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := getAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername) + if err != nil { + if errors.Is(err, ErrNewUsernameRequired) { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if errors.Is(err, ErrUsernameTaken) { + c.JSON(409, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "account not found"}) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"token": token}) +} + +func (h *AccountHandler) ChangePassword(c *gin.Context) { + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil { + c.JSON(400, gin.H{"error": "unsuccessfully password changed"}) + return + } + c.JSON(200, gin.H{"message": "successfully password changed"}) +} + +func (h *AccountHandler) FindByID(c *gin.Context) { + var req FindByIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, account) + } +} + +func (h *AccountHandler) FindByUsername(c *gin.Context) { + var req FindByUsernameRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, account) + } +} + +func (h *AccountHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username}) +} + +func (h *AccountHandler) Logout(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "account logged out"}) +} + +func (h *AccountHandler) UploadAvatar(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + const maxSize = 10 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + ext := strings.ToLower(filepath.Ext(f.Filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"}) + return + } + dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10)) + if err := os.MkdirAll(dir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + filename = filename + ext + absPath := filepath.Join(dir, filename) + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename) + avatarURL := buildAbsoluteURL(c, urlPath) + if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL}) +} + +func (h *AccountHandler) UpdateProfile(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) +} + +func (h *AccountHandler) Refresh(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username}) +} + +func randHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(b), nil +} + +func buildAbsoluteURL(c *gin.Context, p string) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { + scheme = xf + } + return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) +} + +func getAccountID(c *gin.Context) (uint, error) { + value, exists := c.Get("accountID") + if !exists { + return 0, errors.New("accountID not found") + } + id, ok := value.(uint) + if !ok { + return 0, errors.New("accountID has invalid type") + } + return id, nil +} diff --git a/backend/internal/account/repo.go b/backend/internal/account/repo.go index 316d23a..ddf285e 100644 --- a/backend/internal/account/repo.go +++ b/backend/internal/account/repo.go @@ -1,106 +1,106 @@ -package account - -import ( - "context" - - "gorm.io/gorm" -) - -type AccountRepository struct { - db *gorm.DB -} - -func NewAccountRepository(db *gorm.DB) *AccountRepository { - return &AccountRepository{db: db} -} - -func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error { - if err := ar.db.WithContext(ctx).Create(account).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error { - result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error { - return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil { - return err - } - return nil - }) -} - -func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) { - var account Account - if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil { - return nil, err - } - return &account, nil -} - -func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) { - var account Account - if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil { - return nil, err - } - return &account, nil -} - -func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) Logout(ctx context.Context, id uint) error { - if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil { - return err - } - return nil -} - -func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { - return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error -} - -func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error { - return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error -} - -func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error { - return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error -} - -func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) { - var accounts []*Account - if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil { - return nil, err - } - return accounts, nil -} +package account + +import ( + "context" + + "gorm.io/gorm" +) + +type AccountRepository struct { + db *gorm.DB +} + +func NewAccountRepository(db *gorm.DB) *AccountRepository { + return &AccountRepository{db: db} +} + +func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error { + if err := ar.db.WithContext(ctx).Create(account).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error { + result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error { + return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil { + return err + } + return nil + }) +} + +func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) { + var account Account + if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil { + return nil, err + } + return &account, nil +} + +func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) { + var account Account + if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil { + return nil, err + } + return &account, nil +} + +func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) Logout(ctx context.Context, id uint) error { + if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil { + return err + } + return nil +} + +func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error +} + +func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error +} + +func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error { + return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error +} + +func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) { + var accounts []*Account + if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} diff --git a/backend/internal/account/service.go b/backend/internal/account/service.go index 406109b..4f1e85c 100644 --- a/backend/internal/account/service.go +++ b/backend/internal/account/service.go @@ -1,236 +1,236 @@ -package account - -import ( - "context" - "errors" - "feedsystem_video_go/internal/auth" - "log" - "strconv" - "strings" - "time" - - rediscache "feedsystem_video_go/internal/middleware/redis" - - "github.com/go-sql-driver/mysql" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -type AccountService struct { - accountRepository *AccountRepository - cache *rediscache.Client -} - -var ( - ErrUsernameTaken = errors.New("username already exists") - ErrNewUsernameRequired = errors.New("new_username is required") -) - -func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService { - return &AccountService{accountRepository: accountRepository, cache: cache} -} - -func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error { - passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost) - if err != nil { - return err - } - account.Password = string(passwordHash) - if err := as.accountRepository.CreateAccount(ctx, account); err != nil { - return err - } - return nil -} - -func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) { - if newUsername == "" { - return "", ErrNewUsernameRequired - } - - token, err := auth.GenerateToken(accountID, newUsername) - if err != nil { - return "", err - } - - if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil { - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return "", ErrUsernameTaken - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return "", err - } - return "", err - } - if as.cache != nil { - cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", accountID), []byte(token), 24*time.Hour); err != nil { - log.Printf("failed to set cache: %v", err) - } - } - return token, nil -} - -func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error { - account, err := as.FindByUsername(ctx, username) - if err != nil { - return err - } - if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil { - return err - } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil { - return err - } - if err := as.Logout(ctx, account.ID); err != nil { - return err - } - return nil -} - -func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) { - if account, err := as.accountRepository.FindByID(ctx, id); err != nil { - return nil, err - } else { - return account, nil - } -} - -func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) { - if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil { - return nil, err - } else { - return account, nil - } -} - -func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) { - account, err := as.FindByUsername(ctx, username) - if err != nil { - return "", "", err - } - if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil { - return "", "", err - } - accessToken, err := auth.GenerateToken(account.ID, account.Username) - if err != nil { - return "", "", err - } - refreshToken, err := auth.GenerateRefreshToken(account.ID) - if err != nil { - return "", "", err - } - if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil { - return "", "", err - } - if as.cache != nil { - cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil { - log.Printf("failed to set cache: %v", err) - } - if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil { - log.Printf("failed to set refresh cache: %v", err) - } - if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil { - log.Printf("failed to set refresh lookup: %v", err) - } - } - return accessToken, refreshToken, nil -} - -func (as *AccountService) Logout(ctx context.Context, accountID uint) error { - account, err := as.FindByID(ctx, accountID) - if err != nil { - return err - } - if account.Token == "" { - return nil - } - if as.cache != nil { - cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil { - log.Printf("failed to del cache: %v", err) - } - if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil { - log.Printf("failed to del refresh cache: %v", err) - } - if account.RefreshToken != "" { - as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken)) - } - } - return as.accountRepository.Logout(ctx, account.ID) -} - -func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { - return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL) -} - -func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) { - return as.accountRepository.FindAll(ctx) -} - -func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error { - updates := map[string]interface{}{} - if req.Bio != "" { - updates["bio"] = strings.TrimSpace(req.Bio) - } - if req.AvatarURL != "" { - updates["avatar_url"] = strings.TrimSpace(req.AvatarURL) - } - if len(updates) == 0 { - return errors.New("nothing to update") - } - return as.accountRepository.UpdateFields(ctx, accountID, updates) -} - -func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) { - if refreshToken == "" { - return "", 0, "", errors.New("refresh token is empty") - } - if as.cache != nil { - cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken)) - if err == nil { - idStr := string(b) - id, parseErr := strconv.ParseUint(idStr, 10, 64) - if parseErr == nil { - account, err := as.FindByID(ctx, uint(id)) - if err == nil && account != nil && account.RefreshToken == refreshToken { - newToken, err := auth.GenerateToken(account.ID, account.Username) - if err != nil { - return "", 0, "", err - } - as.accountRepository.UpdateToken(ctx, account.ID, newToken) - as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour) - return newToken, account.ID, account.Username, nil - } - } - } - } - accounts, err := as.FindAll(ctx) - if err != nil { - return "", 0, "", err - } - for _, acc := range accounts { - if acc.RefreshToken == refreshToken { - newToken, err := auth.GenerateToken(acc.ID, acc.Username) - if err != nil { - return "", 0, "", err - } - as.accountRepository.UpdateToken(ctx, acc.ID, newToken) - return newToken, acc.ID, acc.Username, nil - } - } - return "", 0, "", errors.New("invalid refresh token") -} +package account + +import ( + "context" + "errors" + "feedsystem_video_go/internal/auth" + "log" + "strconv" + "strings" + "time" + + rediscache "feedsystem_video_go/internal/middleware/redis" + + "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AccountService struct { + accountRepository *AccountRepository + cache *rediscache.Client +} + +var ( + ErrUsernameTaken = errors.New("username already exists") + ErrNewUsernameRequired = errors.New("new_username is required") +) + +func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService { + return &AccountService{accountRepository: accountRepository, cache: cache} +} + +func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + account.Password = string(passwordHash) + if err := as.accountRepository.CreateAccount(ctx, account); err != nil { + return err + } + return nil +} + +func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) { + if newUsername == "" { + return "", ErrNewUsernameRequired + } + + token, err := auth.GenerateToken(accountID, newUsername) + if err != nil { + return "", err + } + + if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return "", ErrUsernameTaken + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + return "", err + } + if as.cache != nil { + cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", accountID), []byte(token), 24*time.Hour); err != nil { + log.Printf("failed to set cache: %v", err) + } + } + return token, nil +} + +func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error { + account, err := as.FindByUsername(ctx, username) + if err != nil { + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil { + return err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil { + return err + } + if err := as.Logout(ctx, account.ID); err != nil { + return err + } + return nil +} + +func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) { + if account, err := as.accountRepository.FindByID(ctx, id); err != nil { + return nil, err + } else { + return account, nil + } +} + +func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) { + if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil { + return nil, err + } else { + return account, nil + } +} + +func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) { + account, err := as.FindByUsername(ctx, username) + if err != nil { + return "", "", err + } + if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil { + return "", "", err + } + accessToken, err := auth.GenerateToken(account.ID, account.Username) + if err != nil { + return "", "", err + } + refreshToken, err := auth.GenerateRefreshToken(account.ID) + if err != nil { + return "", "", err + } + if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil { + return "", "", err + } + if as.cache != nil { + cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil { + log.Printf("failed to set cache: %v", err) + } + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil { + log.Printf("failed to set refresh cache: %v", err) + } + if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil { + log.Printf("failed to set refresh lookup: %v", err) + } + } + return accessToken, refreshToken, nil +} + +func (as *AccountService) Logout(ctx context.Context, accountID uint) error { + account, err := as.FindByID(ctx, accountID) + if err != nil { + return err + } + if account.Token == "" { + return nil + } + if as.cache != nil { + cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil { + log.Printf("failed to del cache: %v", err) + } + if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil { + log.Printf("failed to del refresh cache: %v", err) + } + if account.RefreshToken != "" { + as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken)) + } + } + return as.accountRepository.Logout(ctx, account.ID) +} + +func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { + return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL) +} + +func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) { + return as.accountRepository.FindAll(ctx) +} + +func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error { + updates := map[string]interface{}{} + if req.Bio != "" { + updates["bio"] = strings.TrimSpace(req.Bio) + } + if req.AvatarURL != "" { + updates["avatar_url"] = strings.TrimSpace(req.AvatarURL) + } + if len(updates) == 0 { + return errors.New("nothing to update") + } + return as.accountRepository.UpdateFields(ctx, accountID, updates) +} + +func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) { + if refreshToken == "" { + return "", 0, "", errors.New("refresh token is empty") + } + if as.cache != nil { + cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken)) + if err == nil { + idStr := string(b) + id, parseErr := strconv.ParseUint(idStr, 10, 64) + if parseErr == nil { + account, err := as.FindByID(ctx, uint(id)) + if err == nil && account != nil && account.RefreshToken == refreshToken { + newToken, err := auth.GenerateToken(account.ID, account.Username) + if err != nil { + return "", 0, "", err + } + as.accountRepository.UpdateToken(ctx, account.ID, newToken) + as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour) + return newToken, account.ID, account.Username, nil + } + } + } + } + accounts, err := as.FindAll(ctx) + if err != nil { + return "", 0, "", err + } + for _, acc := range accounts { + if acc.RefreshToken == refreshToken { + newToken, err := auth.GenerateToken(acc.ID, acc.Username) + if err != nil { + return "", 0, "", err + } + as.accountRepository.UpdateToken(ctx, acc.ID, newToken) + return newToken, acc.ID, acc.Username, nil + } + } + return "", 0, "", errors.New("invalid refresh token") +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index a441fc8..35a8a7b 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,82 +1,82 @@ -// internal/auth/jwt.go -package auth - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "log" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func jwtSecret() []byte { - secret := os.Getenv("JWT_SECRET") - if secret == "" { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - log.Printf("FATAL: cannot generate JWT secret: %v", err) - return []byte("fallback-unsafe-key-change-me") - } - secret = hex.EncodeToString(b) - log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.") - } - return []byte(secret) -} - -type Claims struct { - AccountID uint `json:"account_id"` - Username string `json:"username"` - jwt.RegisteredClaims -} - -func GenerateToken(accountID uint, username string) (string, error) { - now := time.Now() - - claims := Claims{ - AccountID: accountID, - Username: username, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - return token.SignedString(jwtSecret()) -} - -func GenerateRefreshToken(accountID uint) (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -func ParseToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims( - tokenString, - &Claims{}, - func(token *jwt.Token) (interface{}, error) { - if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, errors.New("unexpected signing method") - } - return jwtSecret(), nil - }, - ) - if err != nil { - return nil, err - } - - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, jwt.ErrTokenInvalidClaims - } - - return claims, nil -} +// internal/auth/jwt.go +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "log" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func jwtSecret() []byte { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Printf("FATAL: cannot generate JWT secret: %v", err) + return []byte("fallback-unsafe-key-change-me") + } + secret = hex.EncodeToString(b) + log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.") + } + return []byte(secret) +} + +type Claims struct { + AccountID uint `json:"account_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +func GenerateToken(accountID uint, username string) (string, error) { + now := time.Now() + + claims := Claims{ + AccountID: accountID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString(jwtSecret()) +} + +func GenerateRefreshToken(accountID uint) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(token *jwt.Token) (interface{}, error) { + if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, errors.New("unexpected signing method") + } + return jwtSecret(), nil + }, + ) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + + return claims, nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 5964de2..34272db 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -1,42 +1,42 @@ -package db - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/config" - "feedsystem_video_go/internal/message" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "feedsystem_video_go/internal/worker" - "fmt" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) { - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", - dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName) - - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - return nil, err - } - - return db, nil -} - -func AutoMigrate(db *gorm.DB) error { - return db.AutoMigrate( - &account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{}, - &social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{}, - &message.Message{}, &worker.Notification{}, - ) -} - -func CloseDB(db *gorm.DB) error { - sqlDB, err := db.DB() - if err != nil { - return err - } - return sqlDB.Close() -} +package db + +import ( + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/config" + "feedsystem_video_go/internal/message" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "feedsystem_video_go/internal/worker" + "fmt" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, err + } + + return db, nil +} + +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{}, + &social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{}, + &message.Message{}, &worker.Notification{}, + ) +} + +func CloseDB(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/backend/internal/feed/entity.go b/backend/internal/feed/entity.go index bd0f921..808c218 100644 --- a/backend/internal/feed/entity.go +++ b/backend/internal/feed/entity.go @@ -1,82 +1,82 @@ -package feed - -import "time" - -type FeedAuthor struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type FeedVideoItem struct { - ID uint `json:"id"` - Author FeedAuthor `json:"author"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - PlayURL string `json:"play_url"` - CoverURL string `json:"cover_url"` - CreateTime int64 `json:"create_time"` - LikesCount int64 `json:"likes_count"` - IsLiked bool `json:"is_liked"` -} - -type ListLatestRequest struct { - Limit int `json:"limit"` - LatestTime int64 `json:"latest_time"` -} - -type ListLatestResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextTime int64 `json:"next_time"` - HasMore bool `json:"has_more"` -} - -type ListLikesCountRequest struct { - Limit int `json:"limit"` - LikesCountBefore *int64 `json:"likes_count_before,omitempty"` - IDBefore *uint `json:"id_before,omitempty"` -} - -type LikesCountCursor struct { - LikesCount int64 - ID uint -} - -type ListLikesCountResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"` - NextIDBefore *uint `json:"next_id_before,omitempty"` - HasMore bool `json:"has_more"` -} - -type ListByFollowingRequest struct { - Limit int `json:"limit"` - LatestTime int64 `json:"latest_time"` -} - -type ListByFollowingResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextTime int64 `json:"next_time"` - HasMore bool `json:"has_more"` -} - -type ListByPopularityRequest struct { - Limit int `json:"limit"` - AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0 - Offset int `json:"offset"` // 下一页从这里开始;第一页传0 - LatestIDBefore *uint `json:"latest_id_before,omitempty"` - - // DB fallback 用(可选) - LatestPopularity int64 `json:"latest_popularity"` - LatestBefore time.Time `json:"latest_before"` -} - -type ListByPopularityResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - AsOf int64 `json:"as_of"` - NextOffset int `json:"next_offset"` - HasMore bool `json:"has_more"` - - NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"` - NextLatestBefore *time.Time `json:"next_latest_before,omitempty"` - NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"` -} +package feed + +import "time" + +type FeedAuthor struct { + ID uint `json:"id"` + Username string `json:"username"` +} + +type FeedVideoItem struct { + ID uint `json:"id"` + Author FeedAuthor `json:"author"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + PlayURL string `json:"play_url"` + CoverURL string `json:"cover_url"` + CreateTime int64 `json:"create_time"` + LikesCount int64 `json:"likes_count"` + IsLiked bool `json:"is_liked"` +} + +type ListLatestRequest struct { + Limit int `json:"limit"` + LatestTime int64 `json:"latest_time"` +} + +type ListLatestResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextTime int64 `json:"next_time"` + HasMore bool `json:"has_more"` +} + +type ListLikesCountRequest struct { + Limit int `json:"limit"` + LikesCountBefore *int64 `json:"likes_count_before,omitempty"` + IDBefore *uint `json:"id_before,omitempty"` +} + +type LikesCountCursor struct { + LikesCount int64 + ID uint +} + +type ListLikesCountResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"` + NextIDBefore *uint `json:"next_id_before,omitempty"` + HasMore bool `json:"has_more"` +} + +type ListByFollowingRequest struct { + Limit int `json:"limit"` + LatestTime int64 `json:"latest_time"` +} + +type ListByFollowingResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextTime int64 `json:"next_time"` + HasMore bool `json:"has_more"` +} + +type ListByPopularityRequest struct { + Limit int `json:"limit"` + AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0 + Offset int `json:"offset"` // 下一页从这里开始;第一页传0 + LatestIDBefore *uint `json:"latest_id_before,omitempty"` + + // DB fallback 用(可选) + LatestPopularity int64 `json:"latest_popularity"` + LatestBefore time.Time `json:"latest_before"` +} + +type ListByPopularityResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + AsOf int64 `json:"as_of"` + NextOffset int `json:"next_offset"` + HasMore bool `json:"has_more"` + + NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"` + NextLatestBefore *time.Time `json:"next_latest_before,omitempty"` + NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"` +} diff --git a/backend/internal/feed/handler.go b/backend/internal/feed/handler.go index efb2789..0c91e1f 100644 --- a/backend/internal/feed/handler.go +++ b/backend/internal/feed/handler.go @@ -1,201 +1,201 @@ -package feed - -import ( - "feedsystem_video_go/internal/middleware/jwt" - "feedsystem_video_go/internal/apierror" - "time" - - "github.com/gin-gonic/gin" -) - -type FeedHandler struct { - service *FeedService -} - -func NewFeedHandler(service *FeedService) *FeedHandler { - return &FeedHandler{service: service} -} - -func (f *FeedHandler) ListLatest(c *gin.Context) { - var req ListLatestRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - var latestTime time.Time - if req.LatestTime > 0 { - latestTime = time.UnixMilli(req.LatestTime) - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListLikesCount(c *gin.Context) { - var req ListLikesCountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - - var cursor *LikesCountCursor - if req.LikesCountBefore != nil || req.IDBefore != nil { - if req.LikesCountBefore == nil || req.IDBefore == nil { - c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"}) - return - } - - likesCountBefore := *req.LikesCountBefore - idBefore := *req.IDBefore - - if likesCountBefore < 0 { - c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"}) - return - } - if idBefore == 0 { - if likesCountBefore != 0 { - c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"}) - return - } - } else { - cursor = &LikesCountCursor{ - LikesCount: likesCountBefore, - ID: idBefore, - } - } - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListByFollowing(c *gin.Context) { - var req ListByFollowingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - var latestTime time.Time - if req.LatestTime > 0 { - latestTime = time.Unix(req.LatestTime, 0) - } - feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListByPopularity(c *gin.Context) { - var req ListByPopularityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - - var latestPopularity int64 - var latestBefore time.Time - var latestIDBefore uint - - if req.LatestPopularity < 0 { - c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) - return - } - - anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil - if anyCursor { - if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { - c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) - return - } - latestPopularity = req.LatestPopularity - latestBefore = req.LatestBefore - latestIDBefore = *req.LatestIDBefore - } - resp, err := f.service.ListByPopularity( - c.Request.Context(), - req.Limit, - req.AsOf, - req.Offset, - viewerAccountID, - latestPopularity, - latestBefore, - latestIDBefore, - ) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - resp.VideoList = nonNilFeedVideoItems(resp.VideoList) - c.JSON(200, resp) -} - -func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem { - if items == nil { - return []FeedVideoItem{} - } - return items -} - -func (h *FeedHandler) ListByTag(c *gin.Context) { - var req struct { - TagName string `json:"tag_name"` - Limit int `json:"limit"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.TagName == "" { - c.JSON(400, gin.H{"error": "tag_name is required"}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - viewerAccountID, _ := jwt.GetAccountID(c) - items, err := h.service.ListByTag(c.Request.Context(), req.TagName, req.Limit, viewerAccountID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"video_list": nonNilFeedVideoItems(items)}) -} +package feed + +import ( + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/jwt" + "time" + + "github.com/gin-gonic/gin" +) + +type FeedHandler struct { + service *FeedService +} + +func NewFeedHandler(service *FeedService) *FeedHandler { + return &FeedHandler{service: service} +} + +func (f *FeedHandler) ListLatest(c *gin.Context) { + var req ListLatestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + var latestTime time.Time + if req.LatestTime > 0 { + latestTime = time.UnixMilli(req.LatestTime) + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListLikesCount(c *gin.Context) { + var req ListLikesCountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + + var cursor *LikesCountCursor + if req.LikesCountBefore != nil || req.IDBefore != nil { + if req.LikesCountBefore == nil || req.IDBefore == nil { + c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"}) + return + } + + likesCountBefore := *req.LikesCountBefore + idBefore := *req.IDBefore + + if likesCountBefore < 0 { + c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"}) + return + } + if idBefore == 0 { + if likesCountBefore != 0 { + c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"}) + return + } + } else { + cursor = &LikesCountCursor{ + LikesCount: likesCountBefore, + ID: idBefore, + } + } + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListByFollowing(c *gin.Context) { + var req ListByFollowingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + var latestTime time.Time + if req.LatestTime > 0 { + latestTime = time.Unix(req.LatestTime, 0) + } + feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListByPopularity(c *gin.Context) { + var req ListByPopularityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + + var latestPopularity int64 + var latestBefore time.Time + var latestIDBefore uint + + if req.LatestPopularity < 0 { + c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) + return + } + + anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil + if anyCursor { + if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { + c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) + return + } + latestPopularity = req.LatestPopularity + latestBefore = req.LatestBefore + latestIDBefore = *req.LatestIDBefore + } + resp, err := f.service.ListByPopularity( + c.Request.Context(), + req.Limit, + req.AsOf, + req.Offset, + viewerAccountID, + latestPopularity, + latestBefore, + latestIDBefore, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + resp.VideoList = nonNilFeedVideoItems(resp.VideoList) + c.JSON(200, resp) +} + +func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem { + if items == nil { + return []FeedVideoItem{} + } + return items +} + +func (h *FeedHandler) ListByTag(c *gin.Context) { + var req struct { + TagName string `json:"tag_name"` + Limit int `json:"limit"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if req.TagName == "" { + c.JSON(400, gin.H{"error": "tag_name is required"}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, _ := jwt.GetAccountID(c) + items, err := h.service.ListByTag(c.Request.Context(), req.TagName, req.Limit, viewerAccountID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"video_list": nonNilFeedVideoItems(items)}) +} diff --git a/backend/internal/feed/repo.go b/backend/internal/feed/repo.go index 129cc0e..c54c3a3 100644 --- a/backend/internal/feed/repo.go +++ b/backend/internal/feed/repo.go @@ -1,115 +1,115 @@ -package feed - -import ( - "context" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "time" - - "gorm.io/gorm" -) - -type FeedRepository struct { - db *gorm.DB -} - -func NewFeedRepository(db *gorm.DB) *FeedRepository { - return &FeedRepository{db: db} -} - -func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("create_time DESC") - if !latestBefore.IsZero() { - query = query.Where("create_time < ?", latestBefore) - } - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("likes_count DESC, id DESC") - - if cursor != nil { - query = query.Where( - "(likes_count < ?) OR (likes_count = ? AND id < ?)", - cursor.LikesCount, - cursor.LikesCount, cursor.ID, - ) - } - - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("create_time DESC") - if viewerAccountID > 0 { - followingSubQuery := repo.db.WithContext(ctx). - Model(&social.Social{}). - Select("vlogger_id"). - Where("follower_id = ?", viewerAccountID) - query = query.Where("author_id IN (?)", followingSubQuery) - } - if !latestBefore.IsZero() { - query = query.Where("create_time < ?", latestBefore) - } - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("popularity DESC, create_time DESC, id DESC") - - // 只有当游标完整提供时才加过滤(popularity 允许为 0) - if !timeBefore.IsZero() && idBefore > 0 { - query = query.Where( - "(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)", - popularityBefore, - popularityBefore, timeBefore, - popularityBefore, timeBefore, idBefore, - ) - } - - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) { - var videos []*video.Video - if len(ids) == 0 { - return videos, nil - } - if err := repo.db.WithContext(ctx).Model(&video.Video{}). - Where("id IN ?", ids).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) { - var videos []*video.Video - err := repo.db.WithContext(ctx).Model(&video.Video{}).Table("videos"). - Joins("JOIN video_tags ON video_tags.video_id = videos.id"). - Joins("JOIN tags ON tags.id = video_tags.tag_id"). - Where("tags.name = ?", tagName). - Order("videos.create_time desc"). - Limit(limit). - Find(&videos).Error - return videos, err +package feed + +import ( + "context" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "time" + + "gorm.io/gorm" +) + +type FeedRepository struct { + db *gorm.DB +} + +func NewFeedRepository(db *gorm.DB) *FeedRepository { + return &FeedRepository{db: db} +} + +func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("create_time DESC") + if !latestBefore.IsZero() { + query = query.Where("create_time < ?", latestBefore) + } + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("likes_count DESC, id DESC") + + if cursor != nil { + query = query.Where( + "(likes_count < ?) OR (likes_count = ? AND id < ?)", + cursor.LikesCount, + cursor.LikesCount, cursor.ID, + ) + } + + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("create_time DESC") + if viewerAccountID > 0 { + followingSubQuery := repo.db.WithContext(ctx). + Model(&social.Social{}). + Select("vlogger_id"). + Where("follower_id = ?", viewerAccountID) + query = query.Where("author_id IN (?)", followingSubQuery) + } + if !latestBefore.IsZero() { + query = query.Where("create_time < ?", latestBefore) + } + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("popularity DESC, create_time DESC, id DESC") + + // 只有当游标完整提供时才加过滤(popularity 允许为 0) + if !timeBefore.IsZero() && idBefore > 0 { + query = query.Where( + "(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)", + popularityBefore, + popularityBefore, timeBefore, + popularityBefore, timeBefore, idBefore, + ) + } + + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) { + var videos []*video.Video + if len(ids) == 0 { + return videos, nil + } + if err := repo.db.WithContext(ctx).Model(&video.Video{}). + Where("id IN ?", ids).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) { + var videos []*video.Video + err := repo.db.WithContext(ctx).Model(&video.Video{}).Table("videos"). + Joins("JOIN video_tags ON video_tags.video_id = videos.id"). + Joins("JOIN tags ON tags.id = video_tags.tag_id"). + Where("tags.name = ?", tagName). + Order("videos.create_time desc"). + Limit(limit). + Find(&videos).Error + return videos, err } diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index 6431930..46912e3 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -1,242 +1,242 @@ -package http - -import ( - "context" - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/feed" - "feedsystem_video_go/internal/message" - "feedsystem_video_go/internal/middleware/jwt" - "feedsystem_video_go/internal/middleware/ratelimit" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "feedsystem_video_go/internal/worker" - "log" - "time" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine { - r := gin.Default() - if err := r.SetTrustedProxies(nil); err != nil { - log.Printf("SetTrustedProxies failed: %v", err) - } - r.Static("/static", "./.run/uploads") - // rate_limit - loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP) - registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP) - - likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount) - commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount) - socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount) - - // account - accountRepository := account.NewAccountRepository(db) - accountService := account.NewAccountService(accountRepository, cache) - accountHandler := account.NewAccountHandler(accountService) - accountGroup := r.Group("/account") - { - accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount) - accountGroup.POST("/login", loginLimiter, accountHandler.Login) - accountGroup.POST("/changePassword", accountHandler.ChangePassword) - accountGroup.POST("/findByID", accountHandler.FindByID) - accountGroup.POST("/findByUsername", accountHandler.FindByUsername) - accountGroup.POST("/refresh", accountHandler.Refresh) - } - protectedAccountGroup := accountGroup.Group("") - protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedAccountGroup.POST("/logout", accountHandler.Logout) - protectedAccountGroup.POST("/rename", accountHandler.Rename) - protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar) - protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile) - } - // video - videoRepository := video.NewVideoRepository(db) - popularityMQ, err := rabbitmq.NewPopularityMQ(rmq) - if err != nil { - log.Printf("PopularityMQ init failed (mq disabled): %v", err) - popularityMQ = nil - } - videoService := video.NewVideoService(videoRepository, cache, popularityMQ) - videoHandler := video.NewVideoHandler(videoService, accountService) - chunkHandler := video.NewChunkUploadHandler(cache) - videoGroup := r.Group("/video") - { - videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID) - videoGroup.POST("/getDetail", videoHandler.GetDetail) - } - protectedVideoGroup := videoGroup.Group("") - protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo) - protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover) - protectedVideoGroup.POST("/publish", videoHandler.PublishVideo) - protectedVideoGroup.POST("/chunk/init", chunkHandler.InitChunkUpload) - protectedVideoGroup.POST("/chunk/upload", chunkHandler.UploadChunk) - protectedVideoGroup.POST("/chunk/status", chunkHandler.ChunkStatus) - protectedVideoGroup.POST("/chunk/complete", chunkHandler.CompleteChunkUpload) - } - // like - likeMQ, err := rabbitmq.NewLikeMQ(rmq) - if err != nil { - log.Printf("LikeMQ init failed (mq disabled): %v", err) - likeMQ = nil - } - likeRepository := video.NewLikeRepository(db) - likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ) - likeHandler := video.NewLikeHandler(likeService) - likeGroup := r.Group("/like") - protectedLikeGroup := likeGroup.Group("") - protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like) - protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike) - protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked) - protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos) - } - // comment - commentRepository := video.NewCommentRepository(db) - commentMQ, err := rabbitmq.NewCommentMQ(rmq) - if err != nil { - log.Printf("CommentMQ init failed (mq disabled): %v", err) - commentMQ = nil - } - commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ) - commentHandler := video.NewCommentHandler(commentService, accountService) - commentGroup := r.Group("/comment") - { - commentGroup.POST("/listAll", commentHandler.GetAllComments) - } - protectedCommentGroup := commentGroup.Group("") - protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment) - protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment) - } - // social - socialMQ, err := rabbitmq.NewSocialMQ(rmq) - if err != nil { - log.Printf("SocialMQ init failed (mq disabled): %v", err) - socialMQ = nil - } - socialRepository := social.NewSocialRepository(db) - socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ) - socialHandler := social.NewSocialHandler(socialService) - socialGroup := r.Group("/social") - protectedSocialGroup := socialGroup.Group("") - protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow) - protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow) - protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers) - protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers) - protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts) - } - - accountGroup.POST("/getProfile", func(c *gin.Context) { - var req account.GetProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.AccountID == 0 { - c.JSON(400, gin.H{"error": "account_id is required"}) - return - } - acc, err := accountService.FindByID(c.Request.Context(), req.AccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID) - totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID) - followerCount, _ := socialRepository.CountFollowers(c.Request.Context(), req.AccountID) - vloggerCount, _ := socialRepository.CountVloggers(c.Request.Context(), req.AccountID) - - c.JSON(200, account.GetProfileResponse{ - Account: account.FindByIDResponse{ID: acc.ID, Username: acc.Username, AvatarURL: acc.AvatarURL, Bio: acc.Bio}, - VideoCount: videoCount, TotalLikes: totalLikes, - FollowerCount: followerCount, VloggerCount: vloggerCount, - }) - }) - // feed - feedRepository := feed.NewFeedRepository(db) - feedService := feed.NewFeedService(feedRepository, likeRepository, cache) - feedHandler := feed.NewFeedHandler(feedService) - feedGroup := r.Group("/feed") - feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache)) - { - feedGroup.POST("/listLatest", feedHandler.ListLatest) - feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) - feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) - feedGroup.POST("/listByTag", feedHandler.ListByTag) - } - protectedFeedGroup := feedGroup.Group("") - protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing) - } - // message - messageRepo := message.NewRepository(db) - messageService := message.NewService(messageRepo) - messageHandler := message.NewHandler(messageService) - messageGroup := r.Group("/message") - protectedMessageGroup := messageGroup.Group("") - protectedMessageGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedMessageGroup.POST("/send", messageHandler.Send) - protectedMessageGroup.POST("/list", messageHandler.List) - } - //worker - timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) - if err != nil { - log.Printf("timelineMQ init failed (mq disabled): %v", err) - timelineMQ = nil - } - worker.StartOutboxPoller(db, timelineMQ) - worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache) - - // SSE notification - if rmq != nil && rmq.Ch != nil { - rmq.DeclareTopic("like.events", "notification.like", "like.like") - rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish") - rmq.DeclareTopic("social.events", "notification.social", "social.follow") - } - sseHub := worker.NewSSEHub(db) - notifGroup := r.Group("/notification") - notifGroup.Use(sseHub.SSERequireAuth()) - sseHub.RegisterRoutes(r, notifGroup) - - go func() { - if rmq != nil && rmq.Ch != nil { - hub := sseHub - ctx := context.Background() - // consume from like queue - go func() { - w := worker.NewNotificationWorker(rmq.Ch, db, "notification.like", hub) - if err := w.Run(ctx); err != nil { - log.Printf("notification-like worker: %v", err) - } - }() - go func() { - w := worker.NewNotificationWorker(rmq.Ch, db, "notification.comment", hub) - if err := w.Run(ctx); err != nil { - log.Printf("notification-comment worker: %v", err) - } - }() - go func() { - w := worker.NewNotificationWorker(rmq.Ch, db, "notification.social", hub) - if err := w.Run(ctx); err != nil { - log.Printf("notification-social worker: %v", err) - } - }() - } else { - log.Printf("Notification SSE disabled (MQ not available)") - } - }() - - return r -} +package http + +import ( + "context" + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/feed" + "feedsystem_video_go/internal/message" + "feedsystem_video_go/internal/middleware/jwt" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/middleware/ratelimit" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "feedsystem_video_go/internal/worker" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "log" + "time" +) + +func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine { + r := gin.Default() + if err := r.SetTrustedProxies(nil); err != nil { + log.Printf("SetTrustedProxies failed: %v", err) + } + r.Static("/static", "./.run/uploads") + // rate_limit + loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP) + registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP) + + likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount) + commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount) + socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount) + + // account + accountRepository := account.NewAccountRepository(db) + accountService := account.NewAccountService(accountRepository, cache) + accountHandler := account.NewAccountHandler(accountService) + accountGroup := r.Group("/account") + { + accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount) + accountGroup.POST("/login", loginLimiter, accountHandler.Login) + accountGroup.POST("/changePassword", accountHandler.ChangePassword) + accountGroup.POST("/findByID", accountHandler.FindByID) + accountGroup.POST("/findByUsername", accountHandler.FindByUsername) + accountGroup.POST("/refresh", accountHandler.Refresh) + } + protectedAccountGroup := accountGroup.Group("") + protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedAccountGroup.POST("/logout", accountHandler.Logout) + protectedAccountGroup.POST("/rename", accountHandler.Rename) + protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar) + protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile) + } + // video + videoRepository := video.NewVideoRepository(db) + popularityMQ, err := rabbitmq.NewPopularityMQ(rmq) + if err != nil { + log.Printf("PopularityMQ init failed (mq disabled): %v", err) + popularityMQ = nil + } + videoService := video.NewVideoService(videoRepository, cache, popularityMQ) + videoHandler := video.NewVideoHandler(videoService, accountService) + chunkHandler := video.NewChunkUploadHandler(cache) + videoGroup := r.Group("/video") + { + videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID) + videoGroup.POST("/getDetail", videoHandler.GetDetail) + } + protectedVideoGroup := videoGroup.Group("") + protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo) + protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover) + protectedVideoGroup.POST("/publish", videoHandler.PublishVideo) + protectedVideoGroup.POST("/chunk/init", chunkHandler.InitChunkUpload) + protectedVideoGroup.POST("/chunk/upload", chunkHandler.UploadChunk) + protectedVideoGroup.POST("/chunk/status", chunkHandler.ChunkStatus) + protectedVideoGroup.POST("/chunk/complete", chunkHandler.CompleteChunkUpload) + } + // like + likeMQ, err := rabbitmq.NewLikeMQ(rmq) + if err != nil { + log.Printf("LikeMQ init failed (mq disabled): %v", err) + likeMQ = nil + } + likeRepository := video.NewLikeRepository(db) + likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ) + likeHandler := video.NewLikeHandler(likeService) + likeGroup := r.Group("/like") + protectedLikeGroup := likeGroup.Group("") + protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like) + protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike) + protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked) + protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos) + } + // comment + commentRepository := video.NewCommentRepository(db) + commentMQ, err := rabbitmq.NewCommentMQ(rmq) + if err != nil { + log.Printf("CommentMQ init failed (mq disabled): %v", err) + commentMQ = nil + } + commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ) + commentHandler := video.NewCommentHandler(commentService, accountService) + commentGroup := r.Group("/comment") + { + commentGroup.POST("/listAll", commentHandler.GetAllComments) + } + protectedCommentGroup := commentGroup.Group("") + protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment) + protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment) + } + // social + socialMQ, err := rabbitmq.NewSocialMQ(rmq) + if err != nil { + log.Printf("SocialMQ init failed (mq disabled): %v", err) + socialMQ = nil + } + socialRepository := social.NewSocialRepository(db) + socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ) + socialHandler := social.NewSocialHandler(socialService) + socialGroup := r.Group("/social") + protectedSocialGroup := socialGroup.Group("") + protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow) + protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow) + protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers) + protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers) + protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts) + } + + accountGroup.POST("/getProfile", func(c *gin.Context) { + var req account.GetProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if req.AccountID == 0 { + c.JSON(400, gin.H{"error": "account_id is required"}) + return + } + acc, err := accountService.FindByID(c.Request.Context(), req.AccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID) + totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID) + followerCount, _ := socialRepository.CountFollowers(c.Request.Context(), req.AccountID) + vloggerCount, _ := socialRepository.CountVloggers(c.Request.Context(), req.AccountID) + + c.JSON(200, account.GetProfileResponse{ + Account: account.FindByIDResponse{ID: acc.ID, Username: acc.Username, AvatarURL: acc.AvatarURL, Bio: acc.Bio}, + VideoCount: videoCount, TotalLikes: totalLikes, + FollowerCount: followerCount, VloggerCount: vloggerCount, + }) + }) + // feed + feedRepository := feed.NewFeedRepository(db) + feedService := feed.NewFeedService(feedRepository, likeRepository, cache) + feedHandler := feed.NewFeedHandler(feedService) + feedGroup := r.Group("/feed") + feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache)) + { + feedGroup.POST("/listLatest", feedHandler.ListLatest) + feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) + feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) + feedGroup.POST("/listByTag", feedHandler.ListByTag) + } + protectedFeedGroup := feedGroup.Group("") + protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing) + } + // message + messageRepo := message.NewRepository(db) + messageService := message.NewService(messageRepo) + messageHandler := message.NewHandler(messageService) + messageGroup := r.Group("/message") + protectedMessageGroup := messageGroup.Group("") + protectedMessageGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedMessageGroup.POST("/send", messageHandler.Send) + protectedMessageGroup.POST("/list", messageHandler.List) + } + //worker + timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) + if err != nil { + log.Printf("timelineMQ init failed (mq disabled): %v", err) + timelineMQ = nil + } + worker.StartOutboxPoller(db, timelineMQ) + worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache) + + // SSE notification + if rmq != nil && rmq.Ch != nil { + rmq.DeclareTopic("like.events", "notification.like", "like.like") + rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish") + rmq.DeclareTopic("social.events", "notification.social", "social.follow") + } + sseHub := worker.NewSSEHub(db) + notifGroup := r.Group("/notification") + notifGroup.Use(sseHub.SSERequireAuth()) + sseHub.RegisterRoutes(r, notifGroup) + + go func() { + if rmq != nil && rmq.Ch != nil { + hub := sseHub + ctx := context.Background() + // consume from like queue + go func() { + w := worker.NewNotificationWorker(rmq.Ch, db, "notification.like", hub) + if err := w.Run(ctx); err != nil { + log.Printf("notification-like worker: %v", err) + } + }() + go func() { + w := worker.NewNotificationWorker(rmq.Ch, db, "notification.comment", hub) + if err := w.Run(ctx); err != nil { + log.Printf("notification-comment worker: %v", err) + } + }() + go func() { + w := worker.NewNotificationWorker(rmq.Ch, db, "notification.social", hub) + if err := w.Run(ctx); err != nil { + log.Printf("notification-social worker: %v", err) + } + }() + } else { + log.Printf("Notification SSE disabled (MQ not available)") + } + }() + + return r +} diff --git a/backend/internal/message/handler.go b/backend/internal/message/handler.go index c7bb291..dcb6fab 100644 --- a/backend/internal/message/handler.go +++ b/backend/internal/message/handler.go @@ -19,8 +19,8 @@ type Service struct{ repo *Repository } type Handler struct{ service *Service } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func NewService(repo *Repository) *Service { return &Service{repo: repo} } +func NewHandler(service *Service) *Handler { return &Handler{service: service} } func (r *Repository) AutoMigrate(ctx context.Context) error { return r.db.WithContext(ctx).AutoMigrate(&Message{}) diff --git a/backend/internal/middleware/jwt/jwt.go b/backend/internal/middleware/jwt/jwt.go index a1a389c..5640d47 100644 --- a/backend/internal/middleware/jwt/jwt.go +++ b/backend/internal/middleware/jwt/jwt.go @@ -1,139 +1,139 @@ -package jwt - -import ( - "context" - "errors" - "log" - "net/http" - "strings" - "time" - - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/auth" - rediscache "feedsystem_video_go/internal/middleware/redis" - - "github.com/gin-gonic/gin" -) - -// JWTAuth check jwt token and ensure it matches the currently stored token. -func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) - return - } - - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) - return - } - - tokenString := parts[1] - - claims, err := auth.ParseToken(tokenString) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) - return - } - check(c, claims, tokenString, accountRepo, cache) - } -} - -func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.Next() - return - } - - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) - return - } - - tokenString := parts[1] - - claims, err := auth.ParseToken(tokenString) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) - return - } - - check(c, claims, tokenString, accountRepo, cache) - } -} - -func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) { - key := cache.Key("account:%d", claims.AccountID) - - // 先查 Redis - if cache != nil { - cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond) - defer cancel() - - b, err := cache.GetBytes(cacheCtx, key) - if err == nil { - if string(b) != tokenString { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) - return - } - c.Set("accountID", claims.AccountID) - c.Set("username", claims.Username) - c.Next() - return - } - } - - // Redis 故障/未启用:查 DB 兜底 - accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID) - if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) - return - } - - if cache != nil { - cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond) - defer cancel() - - if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil { - log.Printf("failed to set cache: %v", err) - } - } - - c.Set("accountID", claims.AccountID) - c.Set("username", claims.Username) - c.Next() - -} - -func GetAccountID(c *gin.Context) (uint, error) { - uidValue, exists := c.Get("accountID") - if !exists { - return 0, errors.New("accountID not found") - } - - accountID, ok := uidValue.(uint) - if !ok { - return 0, errors.New("accountID has invalid type") - } - - return accountID, nil -} - -func GetUsername(c *gin.Context) (string, error) { - val, exists := c.Get("username") - if !exists { - return "", errors.New("username not found") - } - - username, ok := val.(string) - if !ok { - return "", errors.New("username has invalid type") - } - - return username, nil -} +package jwt + +import ( + "context" + "errors" + "log" + "net/http" + "strings" + "time" + + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/auth" + rediscache "feedsystem_video_go/internal/middleware/redis" + + "github.com/gin-gonic/gin" +) + +// JWTAuth check jwt token and ensure it matches the currently stored token. +func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + return + } + + tokenString := parts[1] + + claims, err := auth.ParseToken(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + check(c, claims, tokenString, accountRepo, cache) + } +} + +func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.Next() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + return + } + + tokenString := parts[1] + + claims, err := auth.ParseToken(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + check(c, claims, tokenString, accountRepo, cache) + } +} + +func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) { + key := cache.Key("account:%d", claims.AccountID) + + // 先查 Redis + if cache != nil { + cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond) + defer cancel() + + b, err := cache.GetBytes(cacheCtx, key) + if err == nil { + if string(b) != tokenString { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) + return + } + c.Set("accountID", claims.AccountID) + c.Set("username", claims.Username) + c.Next() + return + } + } + + // Redis 故障/未启用:查 DB 兜底 + accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID) + if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) + return + } + + if cache != nil { + cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond) + defer cancel() + + if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil { + log.Printf("failed to set cache: %v", err) + } + } + + c.Set("accountID", claims.AccountID) + c.Set("username", claims.Username) + c.Next() + +} + +func GetAccountID(c *gin.Context) (uint, error) { + uidValue, exists := c.Get("accountID") + if !exists { + return 0, errors.New("accountID not found") + } + + accountID, ok := uidValue.(uint) + if !ok { + return 0, errors.New("accountID has invalid type") + } + + return accountID, nil +} + +func GetUsername(c *gin.Context) (string, error) { + val, exists := c.Get("username") + if !exists { + return "", errors.New("username not found") + } + + username, ok := val.(string) + if !ok { + return "", errors.New("username has invalid type") + } + + return username, nil +} diff --git a/backend/internal/middleware/rabbitmq/commentMQ.go b/backend/internal/middleware/rabbitmq/commentMQ.go index c1cf0b4..33ca14c 100644 --- a/backend/internal/middleware/rabbitmq/commentMQ.go +++ b/backend/internal/middleware/rabbitmq/commentMQ.go @@ -68,4 +68,3 @@ func (c *CommentMQ) publish(ctx context.Context, action, routingKey string, evt evt.OccurredAt = time.Now().UTC() return c.PublishJSON(ctx, commentExchange, routingKey, evt) } - diff --git a/backend/internal/middleware/rabbitmq/popularityMQ.go b/backend/internal/middleware/rabbitmq/popularityMQ.go index 4f37d4d..572d2f1 100644 --- a/backend/internal/middleware/rabbitmq/popularityMQ.go +++ b/backend/internal/middleware/rabbitmq/popularityMQ.go @@ -54,4 +54,3 @@ func (p *PopularityMQ) Update(ctx context.Context, videoID uint, change int64) e } return p.PublishJSON(ctx, popularityExchange, popularityUpdateRK, event) } - diff --git a/backend/internal/middleware/rabbitmq/rabbitMQ.go b/backend/internal/middleware/rabbitmq/rabbitMQ.go index e527ea1..b665051 100644 --- a/backend/internal/middleware/rabbitmq/rabbitMQ.go +++ b/backend/internal/middleware/rabbitmq/rabbitMQ.go @@ -1,123 +1,123 @@ -package rabbitmq - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "feedsystem_video_go/internal/config" - "log" - "strconv" - "time" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type RabbitMQ struct { - Conn *amqp.Connection - Ch *amqp.Channel -} - -func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) { - if cfg == nil { - return nil, errors.New("rabbitmq config is nil") - } - url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/" - conn, err := amqp.Dial(url) - if err != nil { - return nil, err - } - ch, err := conn.Channel() - if err != nil { - return nil, err - } - return &RabbitMQ{Conn: conn, Ch: ch}, nil -} - -func (r *RabbitMQ) Close() error { - if r == nil || r.Ch == nil || r.Conn == nil { - return nil - } - if err := r.Ch.Close(); err != nil { - return err - } - if err := r.Conn.Close(); err != nil { - return err - } - return nil -} - -func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error { - if r == nil || r.Ch == nil { - return errors.New("rabbitmq is not initialized") - } - if exchange == "" || queue == "" || bindingKey == "" { - return errors.New("exchange/queue/bindingKey is required") - } - - if err := r.Ch.ExchangeDeclare( - exchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := r.Ch.QueueDeclare( - queue, - true, - false, - false, - false, - amqp.Table{"x-dead-letter-exchange": DLXExchange}, - ) - if err != nil { - return err - } - - if err := r.Ch.QueueBind( - q.Name, - bindingKey, - exchange, - false, - nil, - ); err != nil { - return err - } - if err := DeclareDLX(r.Ch, queue); err != nil { - log.Printf("DLX declare failed for %s: %v", queue, err) - } - return nil -} - -func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error { - if r == nil || r.Ch == nil { - return errors.New("rabbitmq is not initialized") - } - if exchange == "" || routingKey == "" { - return errors.New("exchange and routingKey are required") - } - b, err := json.Marshal(payload) - if err != nil { - return err - } - return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ - ContentType: "application/json", - DeliveryMode: amqp.Persistent, - Timestamp: time.Now(), - Body: b, - }) -} - -func newEventID(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} +package rabbitmq + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "feedsystem_video_go/internal/config" + "log" + "strconv" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitMQ struct { + Conn *amqp.Connection + Ch *amqp.Channel +} + +func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) { + if cfg == nil { + return nil, errors.New("rabbitmq config is nil") + } + url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/" + conn, err := amqp.Dial(url) + if err != nil { + return nil, err + } + ch, err := conn.Channel() + if err != nil { + return nil, err + } + return &RabbitMQ{Conn: conn, Ch: ch}, nil +} + +func (r *RabbitMQ) Close() error { + if r == nil || r.Ch == nil || r.Conn == nil { + return nil + } + if err := r.Ch.Close(); err != nil { + return err + } + if err := r.Conn.Close(); err != nil { + return err + } + return nil +} + +func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error { + if r == nil || r.Ch == nil { + return errors.New("rabbitmq is not initialized") + } + if exchange == "" || queue == "" || bindingKey == "" { + return errors.New("exchange/queue/bindingKey is required") + } + + if err := r.Ch.ExchangeDeclare( + exchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := r.Ch.QueueDeclare( + queue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": DLXExchange}, + ) + if err != nil { + return err + } + + if err := r.Ch.QueueBind( + q.Name, + bindingKey, + exchange, + false, + nil, + ); err != nil { + return err + } + if err := DeclareDLX(r.Ch, queue); err != nil { + log.Printf("DLX declare failed for %s: %v", queue, err) + } + return nil +} + +func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error { + if r == nil || r.Ch == nil { + return errors.New("rabbitmq is not initialized") + } + if exchange == "" || routingKey == "" { + return errors.New("exchange and routingKey are required") + } + b, err := json.Marshal(payload) + if err != nil { + return err + } + return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ + ContentType: "application/json", + DeliveryMode: amqp.Persistent, + Timestamp: time.Now(), + Body: b, + }) +} + +func newEventID(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/backend/internal/middleware/ratelimit/ratelimit.go b/backend/internal/middleware/ratelimit/ratelimit.go index 492605b..700b978 100644 --- a/backend/internal/middleware/ratelimit/ratelimit.go +++ b/backend/internal/middleware/ratelimit/ratelimit.go @@ -1,14 +1,14 @@ package ratelimit import ( - rediscache "feedsystem_video_go/internal/middleware/redis" jwt "feedsystem_video_go/internal/middleware/jwt" + rediscache "feedsystem_video_go/internal/middleware/redis" "fmt" + "github.com/gin-gonic/gin" "net/http" + "strconv" "strings" "time" - "strconv" - "github.com/gin-gonic/gin" ) type KeyFunc func(*gin.Context) (string, bool) @@ -68,4 +68,4 @@ func KeyByAccount(c *gin.Context) (string, bool) { return "", false } return strconv.FormatUint(uint64(accountID), 10), true -} \ No newline at end of file +} diff --git a/backend/internal/middleware/redis/redis.go b/backend/internal/middleware/redis/redis.go index 25d55de..19f9697 100644 --- a/backend/internal/middleware/redis/redis.go +++ b/backend/internal/middleware/redis/redis.go @@ -1,115 +1,115 @@ -package redis - -import ( - "context" - "crypto/rand" - "encoding/hex" - "feedsystem_video_go/internal/config" - "fmt" - "strconv" - "time" - - redis "github.com/redis/go-redis/v9" -) - -type Client struct { - rdb *redis.Client - keyPrefix string -} - -const defaultKeyPrefix = "v1:" - -func NewClient(rdb *redis.Client, keyPrefix string) *Client { - return &Client{rdb: rdb, keyPrefix: keyPrefix} -} - -func NewFromEnv(cfg *config.RedisConfig) (*Client, error) { - rdb := redis.NewClient(&redis.Options{ - Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port), - Password: cfg.Password, - DB: cfg.DB, - }) - return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil -} - -func (c *Client) Close() error { - if c == nil || c.rdb == nil { - return nil - } - return c.rdb.Close() -} - -func (c *Client) Ping(ctx context.Context) error { - if c == nil || c.rdb == nil { - return nil - } - return c.rdb.Ping(ctx).Err() -} - -func IsMiss(err error) bool { - return err == redis.Nil -} - -func (c *Client) Key(format string, args ...any) string { - prefix := "" - if c != nil { - prefix = c.keyPrefix - } - return prefix + fmt.Sprintf(format, args...) -} - -func randToken(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) { - if c == nil || c.rdb == nil { - return "", false, nil - } - token, err = randToken(16) - if err != nil { - return "", false, err - } - ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result() - return token, ok, err -} - -var unlockScript = redis.NewScript(` -if redis.call("GET", KEYS[1]) == ARGV[1] then - return redis.call("DEL", KEYS[1]) -else - return 0 -end -`) - -var incrementWithExpireScript = redis.NewScript(` -local count = redis.call("INCR", KEYS[1]) -if count == 1 then - redis.call("PEXPIRE", KEYS[1], ARGV[1]) -end -return count -`) - -func (c *Client) Unlock(ctx context.Context, key string, token string) error { - if c == nil || c.rdb == nil { - return nil - } - _, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result() - return err -} - -func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) { - if c == nil || c.rdb == nil { - return 0, nil - } - return incrementWithExpireScript.Run( - ctx, - c.rdb, - []string{key}, - expire.Milliseconds(), - ).Int64() -} +package redis + +import ( + "context" + "crypto/rand" + "encoding/hex" + "feedsystem_video_go/internal/config" + "fmt" + "strconv" + "time" + + redis "github.com/redis/go-redis/v9" +) + +type Client struct { + rdb *redis.Client + keyPrefix string +} + +const defaultKeyPrefix = "v1:" + +func NewClient(rdb *redis.Client, keyPrefix string) *Client { + return &Client{rdb: rdb, keyPrefix: keyPrefix} +} + +func NewFromEnv(cfg *config.RedisConfig) (*Client, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + }) + return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil +} + +func (c *Client) Close() error { + if c == nil || c.rdb == nil { + return nil + } + return c.rdb.Close() +} + +func (c *Client) Ping(ctx context.Context) error { + if c == nil || c.rdb == nil { + return nil + } + return c.rdb.Ping(ctx).Err() +} + +func IsMiss(err error) bool { + return err == redis.Nil +} + +func (c *Client) Key(format string, args ...any) string { + prefix := "" + if c != nil { + prefix = c.keyPrefix + } + return prefix + fmt.Sprintf(format, args...) +} + +func randToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) { + if c == nil || c.rdb == nil { + return "", false, nil + } + token, err = randToken(16) + if err != nil { + return "", false, err + } + ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result() + return token, ok, err +} + +var unlockScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +else + return 0 +end +`) + +var incrementWithExpireScript = redis.NewScript(` +local count = redis.call("INCR", KEYS[1]) +if count == 1 then + redis.call("PEXPIRE", KEYS[1], ARGV[1]) +end +return count +`) + +func (c *Client) Unlock(ctx context.Context, key string, token string) error { + if c == nil || c.rdb == nil { + return nil + } + _, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result() + return err +} + +func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) { + if c == nil || c.rdb == nil { + return 0, nil + } + return incrementWithExpireScript.Run( + ctx, + c.rdb, + []string{key}, + expire.Milliseconds(), + ).Int64() +} diff --git a/backend/internal/observability/pprof_test.go b/backend/internal/observability/pprof_test.go index 3c45df6..6c28026 100644 --- a/backend/internal/observability/pprof_test.go +++ b/backend/internal/observability/pprof_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "testing" ) + func TestNewPprofMux(t *testing.T) { t.Parallel() @@ -31,7 +32,7 @@ func TestNewPprofServerWithDisabled(t *testing.T) { func TestPprofServerCloseWithDisabledServer(t *testing.T) { t.Parallel() - + pprofServer, err := NewPprofServer("api", false, "localhost:6060") if err != nil { t.Fatalf("Failed to create pprof server: %v", err) @@ -39,4 +40,4 @@ func TestPprofServerCloseWithDisabledServer(t *testing.T) { if err := pprofServer.Close(); err != nil { t.Fatalf("Expected no error when closing disabled pprof server, got: %v", err) } -} \ No newline at end of file +} diff --git a/backend/internal/social/entity.go b/backend/internal/social/entity.go index 4f8f640..14df99d 100644 --- a/backend/internal/social/entity.go +++ b/backend/internal/social/entity.go @@ -1,40 +1,40 @@ -package social - -import "feedsystem_video_go/internal/account" - -type Social struct { - ID uint `gorm:"primaryKey"` - FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"` - VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"` -} - -type FollowRequest struct { - VloggerID uint `json:"vlogger_id"` -} - -type UnfollowRequest struct { - VloggerID uint `json:"vlogger_id"` -} - -type GetAllFollowersRequest struct { - VloggerID uint `json:"vlogger_id"` -} - -type GetAllFollowersResponse struct { - Followers []*account.Account `json:"followers"` - FollowerCount int64 `json:"follower_count"` -} - -type GetAllVloggersResponse struct { - Vloggers []*account.Account `json:"vloggers"` - VloggerCount int64 `json:"vlogger_count"` -} - -type SocialCounts struct { - FollowerCount int64 `json:"follower_count"` - VloggerCount int64 `json:"vlogger_count"` +package social + +import "feedsystem_video_go/internal/account" + +type Social struct { + ID uint `gorm:"primaryKey"` + FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"` + VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"` +} + +type FollowRequest struct { + VloggerID uint `json:"vlogger_id"` +} + +type UnfollowRequest struct { + VloggerID uint `json:"vlogger_id"` +} + +type GetAllFollowersRequest struct { + VloggerID uint `json:"vlogger_id"` +} + +type GetAllFollowersResponse struct { + Followers []*account.Account `json:"followers"` + FollowerCount int64 `json:"follower_count"` +} + +type GetAllVloggersResponse struct { + Vloggers []*account.Account `json:"vloggers"` + VloggerCount int64 `json:"vlogger_count"` +} + +type SocialCounts struct { + FollowerCount int64 `json:"follower_count"` + VloggerCount int64 `json:"vlogger_count"` +} + +type GetAllVloggersRequest struct { + FollowerID uint `json:"follower_id"` } - -type GetAllVloggersRequest struct { - FollowerID uint `json:"follower_id"` -} diff --git a/backend/internal/social/handler.go b/backend/internal/social/handler.go index 0d735b6..817e2e1 100644 --- a/backend/internal/social/handler.go +++ b/backend/internal/social/handler.go @@ -1,139 +1,139 @@ -package social - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/apierror" - "feedsystem_video_go/internal/middleware/jwt" - "net/http" - - "github.com/gin-gonic/gin" -) - -type SocialHandler struct { - service *SocialService -} - -func NewSocialHandler(service *SocialService) *SocialHandler { - return &SocialHandler{service: service} -} - -func (h *SocialHandler) Follow(c *gin.Context) { - var req FollowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VloggerID <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) - return - } - FollowerID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - social := &Social{ - FollowerID: FollowerID, - VloggerID: req.VloggerID, - } - if err := h.service.Follow(c.Request.Context(), social); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "followed"}) -} - -func (h *SocialHandler) Unfollow(c *gin.Context) { - var req UnfollowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VloggerID <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) - return - } - FollowerID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - social := &Social{ - FollowerID: FollowerID, - VloggerID: req.VloggerID, - } - if err := h.service.Unfollow(c.Request.Context(), social); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "unfollowed"}) -} - -func (h *SocialHandler) GetAllFollowers(c *gin.Context) { - var req GetAllFollowersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - vloggerID := req.VloggerID - if vloggerID == 0 { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - vloggerID = accountID - } - - followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if followers == nil { - followers = []*account.Account{} - } - followerCount, _ := h.service.CountFollowers(c.Request.Context(), vloggerID) - c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers, FollowerCount: followerCount}) -} - -func (h *SocialHandler) GetAllVloggers(c *gin.Context) { - var req GetAllVloggersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - followerID := req.FollowerID - if followerID == 0 { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - followerID = accountID - } - - vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if vloggers == nil { - vloggers = []*account.Account{} - } - vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), followerID) - c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers, VloggerCount: vloggerCount}) -} - -func (h *SocialHandler) GetCounts(c *gin.Context) { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - followerCount, _ := h.service.CountFollowers(c.Request.Context(), accountID) - vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), accountID) - c.JSON(http.StatusOK, SocialCounts{FollowerCount: followerCount, VloggerCount: vloggerCount}) -} +package social + +import ( + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/jwt" + "net/http" + + "github.com/gin-gonic/gin" +) + +type SocialHandler struct { + service *SocialService +} + +func NewSocialHandler(service *SocialService) *SocialHandler { + return &SocialHandler{service: service} +} + +func (h *SocialHandler) Follow(c *gin.Context) { + var req FollowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VloggerID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) + return + } + FollowerID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + social := &Social{ + FollowerID: FollowerID, + VloggerID: req.VloggerID, + } + if err := h.service.Follow(c.Request.Context(), social); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "followed"}) +} + +func (h *SocialHandler) Unfollow(c *gin.Context) { + var req UnfollowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VloggerID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) + return + } + FollowerID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + social := &Social{ + FollowerID: FollowerID, + VloggerID: req.VloggerID, + } + if err := h.service.Unfollow(c.Request.Context(), social); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "unfollowed"}) +} + +func (h *SocialHandler) GetAllFollowers(c *gin.Context) { + var req GetAllFollowersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + vloggerID := req.VloggerID + if vloggerID == 0 { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + vloggerID = accountID + } + + followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if followers == nil { + followers = []*account.Account{} + } + followerCount, _ := h.service.CountFollowers(c.Request.Context(), vloggerID) + c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers, FollowerCount: followerCount}) +} + +func (h *SocialHandler) GetAllVloggers(c *gin.Context) { + var req GetAllVloggersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + followerID := req.FollowerID + if followerID == 0 { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + followerID = accountID + } + + vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if vloggers == nil { + vloggers = []*account.Account{} + } + vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), followerID) + c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers, VloggerCount: vloggerCount}) +} + +func (h *SocialHandler) GetCounts(c *gin.Context) { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + followerCount, _ := h.service.CountFollowers(c.Request.Context(), accountID) + vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), accountID) + c.JSON(http.StatusOK, SocialCounts{FollowerCount: followerCount, VloggerCount: vloggerCount}) +} diff --git a/backend/internal/social/repo.go b/backend/internal/social/repo.go index 1f20841..d599f5a 100644 --- a/backend/internal/social/repo.go +++ b/backend/internal/social/repo.go @@ -1,109 +1,109 @@ -package social - -import ( - "context" - "feedsystem_video_go/internal/account" - - "gorm.io/gorm" -) - -type SocialRepository struct { - db *gorm.DB -} - -func NewSocialRepository(db *gorm.DB) *SocialRepository { - return &SocialRepository{db: db} -} - -func (r *SocialRepository) Follow(ctx context.Context, social *Social) error { - return r.db.WithContext(ctx).Create(social).Error -} - -func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error { - return r.db.WithContext(ctx). - Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID). - Delete(&Social{}).Error -} - -func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) { - var relations []Social - if err := r.db.WithContext(ctx). - Model(&Social{}). - Where("vlogger_id = ?", VloggerID). - Limit(200). - Find(&relations).Error; err != nil { - return nil, err - } - - followerIDs := make([]uint, 0, len(relations)) - for _, rel := range relations { - followerIDs = append(followerIDs, rel.FollowerID) - } - if len(followerIDs) == 0 { - return []*account.Account{}, nil - } - - var followers []*account.Account - if err := r.db.WithContext(ctx). - Model(&account.Account{}). - Where("id IN ?", followerIDs). - Find(&followers).Error; err != nil { - return nil, err - } - return followers, nil -} - -func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) { - var relations []Social - if err := r.db.WithContext(ctx). - Model(&Social{}). - Where("follower_id = ?", FollowerID). - Limit(200). - Find(&relations).Error; err != nil { - return nil, err - } - - vloggerIDs := make([]uint, 0, len(relations)) - for _, rel := range relations { - vloggerIDs = append(vloggerIDs, rel.VloggerID) - } - if len(vloggerIDs) == 0 { - return []*account.Account{}, nil - } - - var vloggers []*account.Account - if err := r.db.WithContext(ctx). - Model(&account.Account{}). - Where("id IN ?", vloggerIDs). - Find(&vloggers).Error; err != nil { - return nil, err - } - return vloggers, nil -} - -func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) { - var count int64 - if err := r.db.WithContext(ctx). - Model(&Social{}). - Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID). - Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} - -func (r *SocialRepository) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) { - var count int64 - if err := r.db.WithContext(ctx).Model(&Social{}).Where("vlogger_id = ?", vloggerID).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (r *SocialRepository) CountVloggers(ctx context.Context, followerID uint) (int64, error) { - var count int64 - if err := r.db.WithContext(ctx).Model(&Social{}).Where("follower_id = ?", followerID).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} +package social + +import ( + "context" + "feedsystem_video_go/internal/account" + + "gorm.io/gorm" +) + +type SocialRepository struct { + db *gorm.DB +} + +func NewSocialRepository(db *gorm.DB) *SocialRepository { + return &SocialRepository{db: db} +} + +func (r *SocialRepository) Follow(ctx context.Context, social *Social) error { + return r.db.WithContext(ctx).Create(social).Error +} + +func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error { + return r.db.WithContext(ctx). + Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID). + Delete(&Social{}).Error +} + +func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) { + var relations []Social + if err := r.db.WithContext(ctx). + Model(&Social{}). + Where("vlogger_id = ?", VloggerID). + Limit(200). + Find(&relations).Error; err != nil { + return nil, err + } + + followerIDs := make([]uint, 0, len(relations)) + for _, rel := range relations { + followerIDs = append(followerIDs, rel.FollowerID) + } + if len(followerIDs) == 0 { + return []*account.Account{}, nil + } + + var followers []*account.Account + if err := r.db.WithContext(ctx). + Model(&account.Account{}). + Where("id IN ?", followerIDs). + Find(&followers).Error; err != nil { + return nil, err + } + return followers, nil +} + +func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) { + var relations []Social + if err := r.db.WithContext(ctx). + Model(&Social{}). + Where("follower_id = ?", FollowerID). + Limit(200). + Find(&relations).Error; err != nil { + return nil, err + } + + vloggerIDs := make([]uint, 0, len(relations)) + for _, rel := range relations { + vloggerIDs = append(vloggerIDs, rel.VloggerID) + } + if len(vloggerIDs) == 0 { + return []*account.Account{}, nil + } + + var vloggers []*account.Account + if err := r.db.WithContext(ctx). + Model(&account.Account{}). + Where("id IN ?", vloggerIDs). + Find(&vloggers).Error; err != nil { + return nil, err + } + return vloggers, nil +} + +func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&Social{}). + Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *SocialRepository) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&Social{}).Where("vlogger_id = ?", vloggerID).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *SocialRepository) CountVloggers(ctx context.Context, followerID uint) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&Social{}).Where("follower_id = ?", followerID).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/backend/internal/social/service.go b/backend/internal/social/service.go index 59d1011..dfb6147 100644 --- a/backend/internal/social/service.go +++ b/backend/internal/social/service.go @@ -1,101 +1,101 @@ -package social - -import ( - "context" - "errors" - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/middleware/rabbitmq" -) - -type SocialService struct { - repo *SocialRepository - accountrepo *account.AccountRepository - socialMQ *rabbitmq.SocialMQ -} - -func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService { - return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ} -} - -func (s *SocialService) Follow(ctx context.Context, social *Social) error { - _, err := s.accountrepo.FindByID(ctx, social.FollowerID) - if err != nil { - return err - } - _, err = s.accountrepo.FindByID(ctx, social.VloggerID) - if err != nil { - return err - } - if social.FollowerID == social.VloggerID { - return errors.New("can not follow self") - } - isFollowed, err := s.repo.IsFollowed(ctx, social) - if err != nil { - return err - } - if isFollowed { - return errors.New("already followed") - } - if s.socialMQ != nil { - s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID) - } - return s.repo.Follow(ctx, social) -} - -func (s *SocialService) Unfollow(ctx context.Context, social *Social) error { - _, err := s.accountrepo.FindByID(ctx, social.FollowerID) - if err != nil { - return err - } - _, err = s.accountrepo.FindByID(ctx, social.VloggerID) - if err != nil { - return err - } - isFollowed, err := s.repo.IsFollowed(ctx, social) - if err != nil { - return err - } - if !isFollowed { - return errors.New("not followed") - } - if s.socialMQ != nil { - s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID) - } - return s.repo.Unfollow(ctx, social) -} - -func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) { - _, err := s.accountrepo.FindByID(ctx, VloggerID) - if err != nil { - return nil, err - } - return s.repo.GetAllFollowers(ctx, VloggerID) -} - -func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) { - _, err := s.accountrepo.FindByID(ctx, FollowerID) - if err != nil { - return nil, err - } - return s.repo.GetAllVloggers(ctx, FollowerID) -} - -func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) { - return s.repo.CountFollowers(ctx, vloggerID) -} - -func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) { - return s.repo.CountVloggers(ctx, followerID) -} - -func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) { - _, err := s.accountrepo.FindByID(ctx, social.FollowerID) - if err != nil { - return false, err - } - _, err = s.accountrepo.FindByID(ctx, social.VloggerID) - if err != nil { - return false, err - } - return s.repo.IsFollowed(ctx, social) -} +package social + +import ( + "context" + "errors" + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/middleware/rabbitmq" +) + +type SocialService struct { + repo *SocialRepository + accountrepo *account.AccountRepository + socialMQ *rabbitmq.SocialMQ +} + +func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService { + return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ} +} + +func (s *SocialService) Follow(ctx context.Context, social *Social) error { + _, err := s.accountrepo.FindByID(ctx, social.FollowerID) + if err != nil { + return err + } + _, err = s.accountrepo.FindByID(ctx, social.VloggerID) + if err != nil { + return err + } + if social.FollowerID == social.VloggerID { + return errors.New("can not follow self") + } + isFollowed, err := s.repo.IsFollowed(ctx, social) + if err != nil { + return err + } + if isFollowed { + return errors.New("already followed") + } + if s.socialMQ != nil { + s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID) + } + return s.repo.Follow(ctx, social) +} + +func (s *SocialService) Unfollow(ctx context.Context, social *Social) error { + _, err := s.accountrepo.FindByID(ctx, social.FollowerID) + if err != nil { + return err + } + _, err = s.accountrepo.FindByID(ctx, social.VloggerID) + if err != nil { + return err + } + isFollowed, err := s.repo.IsFollowed(ctx, social) + if err != nil { + return err + } + if !isFollowed { + return errors.New("not followed") + } + if s.socialMQ != nil { + s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID) + } + return s.repo.Unfollow(ctx, social) +} + +func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) { + _, err := s.accountrepo.FindByID(ctx, VloggerID) + if err != nil { + return nil, err + } + return s.repo.GetAllFollowers(ctx, VloggerID) +} + +func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) { + _, err := s.accountrepo.FindByID(ctx, FollowerID) + if err != nil { + return nil, err + } + return s.repo.GetAllVloggers(ctx, FollowerID) +} + +func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) { + return s.repo.CountFollowers(ctx, vloggerID) +} + +func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) { + return s.repo.CountVloggers(ctx, followerID) +} + +func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) { + _, err := s.accountrepo.FindByID(ctx, social.FollowerID) + if err != nil { + return false, err + } + _, err = s.accountrepo.FindByID(ctx, social.VloggerID) + if err != nil { + return false, err + } + return s.repo.IsFollowed(ctx, social) +} diff --git a/backend/internal/video/comment_handler.go b/backend/internal/video/comment_handler.go index 34750c4..687581c 100644 --- a/backend/internal/video/comment_handler.go +++ b/backend/internal/video/comment_handler.go @@ -1,98 +1,98 @@ -package video - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/apierror" - "feedsystem_video_go/internal/middleware/jwt" - - "github.com/gin-gonic/gin" -) - -type CommentHandler struct { - service *CommentService - accountService *account.AccountService -} - -func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler { - return &CommentHandler{service: service, accountService: accountService} -} -func (h *CommentHandler) PublishComment(c *gin.Context) { - var req PublishCommentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.Content == "" { - c.JSON(400, gin.H{"error": "content is required"}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - user, err := h.accountService.FindByID(c.Request.Context(), authorId) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - comment := &Comment{ - Username: user.Username, - VideoID: req.VideoID, - AuthorID: authorId, - Content: req.Content, - } - if err := h.service.Publish(c.Request.Context(), comment); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "comment published successfully"}) -} - -func (h *CommentHandler) DeleteComment(c *gin.Context) { - var req DeleteCommentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.CommentID <= 0 { - c.JSON(400, gin.H{"error": "comment_id is required"}) - return - } - if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{"message": "comment deleted successfully"}) -} - -func (h *CommentHandler) GetAllComments(c *gin.Context) { - var req GetAllCommentsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VideoID == 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - comments, err := h.service.GetAll(c.Request.Context(), req.VideoID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []Comment{} - } - c.JSON(200, comments) -} +package video + +import ( + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/jwt" + + "github.com/gin-gonic/gin" +) + +type CommentHandler struct { + service *CommentService + accountService *account.AccountService +} + +func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler { + return &CommentHandler{service: service, accountService: accountService} +} +func (h *CommentHandler) PublishComment(c *gin.Context) { + var req PublishCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Content == "" { + c.JSON(400, gin.H{"error": "content is required"}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + user, err := h.accountService.FindByID(c.Request.Context(), authorId) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + comment := &Comment{ + Username: user.Username, + VideoID: req.VideoID, + AuthorID: authorId, + Content: req.Content, + } + if err := h.service.Publish(c.Request.Context(), comment); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "comment published successfully"}) +} + +func (h *CommentHandler) DeleteComment(c *gin.Context) { + var req DeleteCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.CommentID <= 0 { + c.JSON(400, gin.H{"error": "comment_id is required"}) + return + } + if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "comment deleted successfully"}) +} + +func (h *CommentHandler) GetAllComments(c *gin.Context) { + var req GetAllCommentsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID == 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + comments, err := h.service.GetAll(c.Request.Context(), req.VideoID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if comments == nil { + comments = []Comment{} + } + c.JSON(200, comments) +} diff --git a/backend/internal/video/comment_repo.go b/backend/internal/video/comment_repo.go index 7de1515..d64882e 100644 --- a/backend/internal/video/comment_repo.go +++ b/backend/internal/video/comment_repo.go @@ -1,55 +1,55 @@ -package video - -import ( - "context" - - "gorm.io/gorm" -) - -type CommentRepository struct { - db *gorm.DB -} - -func NewCommentRepository(db *gorm.DB) *CommentRepository { - return &CommentRepository{db: db} -} - -func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error { - return r.db.WithContext(ctx).Create(comment).Error -} - -func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error { - return r.db.WithContext(ctx).Delete(comment).Error -} - -func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) { - var comments []Comment - err := r.db.WithContext(ctx). - Where("video_id = ?", videoID). - Order("created_at asc"). - Limit(200). - Find(&comments).Error - return comments, err -} - -func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) { - var comment Comment - if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return false, nil - } - return false, err - } - return true, nil -} - -func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) { - var comment Comment - if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, err - } - return &comment, nil -} +package video + +import ( + "context" + + "gorm.io/gorm" +) + +type CommentRepository struct { + db *gorm.DB +} + +func NewCommentRepository(db *gorm.DB) *CommentRepository { + return &CommentRepository{db: db} +} + +func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error { + return r.db.WithContext(ctx).Create(comment).Error +} + +func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error { + return r.db.WithContext(ctx).Delete(comment).Error +} + +func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) { + var comments []Comment + err := r.db.WithContext(ctx). + Where("video_id = ?", videoID). + Order("created_at asc"). + Limit(200). + Find(&comments).Error + return comments, err +} + +func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) { + var comment Comment + if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil + } + return false, err + } + return true, nil +} + +func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) { + var comment Comment + if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &comment, nil +} diff --git a/backend/internal/video/comment_service.go b/backend/internal/video/comment_service.go index d55587a..594be4b 100644 --- a/backend/internal/video/comment_service.go +++ b/backend/internal/video/comment_service.go @@ -1,155 +1,155 @@ -package video - -import ( - "context" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/apierror" - "regexp" - "strings" - - "gorm.io/gorm" -) - -type CommentService struct { - repo *CommentRepository - VideoRepository *VideoRepository - cache *rediscache.Client - commentMQ *rabbitmq.CommentMQ - popularityMQ *rabbitmq.PopularityMQ -} - -func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService { - return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ} -} - -func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { - if comment == nil { - return errors.New("comment is nil") - } - comment.Username = strings.TrimSpace(comment.Username) - comment.Content = strings.TrimSpace(comment.Content) - if comment.VideoID == 0 || comment.AuthorID == 0 { - return errors.New("video_id and author_id are required") - } - if comment.Content == "" { - return errors.New("content is required") - } - - exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID) - if err != nil { - return err - } - if !exists { - return errors.New("video not found") - } - - mysqlEnqueued := false - redisEnqueued := false - if s.commentMQ != nil { - if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil { - mysqlEnqueued = true - } - } - if s.popularityMQ != nil { - if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil { - redisEnqueued = true - } - } - if mysqlEnqueued && redisEnqueued { - s.notifyMentions(ctx, comment) - return nil - } - - // Fallback: direct MySQL write when comment MQ publish fails. - if !mysqlEnqueued { - if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("video not found") - } - return err - } - if err := tx.Create(comment).Error; err != nil { - return err - } - return tx.Model(&Video{}).Where("id = ?", comment.VideoID). - UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error - }); err != nil { - return err - } - } - - // Fallback: direct Redis update when popularity MQ publish fails. - if !redisEnqueued { - UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) - } - s.notifyMentions(ctx, comment) - return nil -} - -func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error { - comment, err := s.repo.GetByID(ctx, commentID) - if err != nil { - return err - } - if comment == nil { - return errors.New("comment not found") - } - if comment.AuthorID != accountID { - return apierror.ErrUnauthorized - } - if s.commentMQ != nil { - if err := s.commentMQ.Delete(ctx, commentID); err == nil { - return nil - } - } - return s.repo.DeleteComment(ctx, comment) -} - -func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) { - exists, err := s.VideoRepository.IsExist(ctx, videoID) - if err != nil { - return nil, err - } - if !exists { - return nil, errors.New("video not found") - } - return s.repo.GetAllComments(ctx, videoID) -} - -var mentionRegex = regexp.MustCompile(`@(\w+)`) - -func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) { - matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1) - if len(matches) == 0 { - return - } - seen := make(map[string]bool) - for _, m := range matches { - username := m[1] - if seen[username] || username == comment.Username { - continue - } - seen[username] = true - var accID uint - if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 { - continue - } - notif := struct { - RecipientID uint - SenderID uint - Type string - TargetID uint - Content string - }{ - RecipientID: accID, - SenderID: comment.AuthorID, - Type: "mention", - TargetID: comment.VideoID, - Content: comment.Username + " 在评论中提到了你", - } - s.repo.db.WithContext(ctx).Table("notifications").Create(¬if) - } -} +package video + +import ( + "context" + "errors" + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + "regexp" + "strings" + + "gorm.io/gorm" +) + +type CommentService struct { + repo *CommentRepository + VideoRepository *VideoRepository + cache *rediscache.Client + commentMQ *rabbitmq.CommentMQ + popularityMQ *rabbitmq.PopularityMQ +} + +func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService { + return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ} +} + +func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { + if comment == nil { + return errors.New("comment is nil") + } + comment.Username = strings.TrimSpace(comment.Username) + comment.Content = strings.TrimSpace(comment.Content) + if comment.VideoID == 0 || comment.AuthorID == 0 { + return errors.New("video_id and author_id are required") + } + if comment.Content == "" { + return errors.New("content is required") + } + + exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID) + if err != nil { + return err + } + if !exists { + return errors.New("video not found") + } + + mysqlEnqueued := false + redisEnqueued := false + if s.commentMQ != nil { + if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil { + mysqlEnqueued = true + } + } + if s.popularityMQ != nil { + if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil { + redisEnqueued = true + } + } + if mysqlEnqueued && redisEnqueued { + s.notifyMentions(ctx, comment) + return nil + } + + // Fallback: direct MySQL write when comment MQ publish fails. + if !mysqlEnqueued { + if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("video not found") + } + return err + } + if err := tx.Create(comment).Error; err != nil { + return err + } + return tx.Model(&Video{}).Where("id = ?", comment.VideoID). + UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error + }); err != nil { + return err + } + } + + // Fallback: direct Redis update when popularity MQ publish fails. + if !redisEnqueued { + UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) + } + s.notifyMentions(ctx, comment) + return nil +} + +func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error { + comment, err := s.repo.GetByID(ctx, commentID) + if err != nil { + return err + } + if comment == nil { + return errors.New("comment not found") + } + if comment.AuthorID != accountID { + return apierror.ErrUnauthorized + } + if s.commentMQ != nil { + if err := s.commentMQ.Delete(ctx, commentID); err == nil { + return nil + } + } + return s.repo.DeleteComment(ctx, comment) +} + +func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) { + exists, err := s.VideoRepository.IsExist(ctx, videoID) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("video not found") + } + return s.repo.GetAllComments(ctx, videoID) +} + +var mentionRegex = regexp.MustCompile(`@(\w+)`) + +func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) { + matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1) + if len(matches) == 0 { + return + } + seen := make(map[string]bool) + for _, m := range matches { + username := m[1] + if seen[username] || username == comment.Username { + continue + } + seen[username] = true + var accID uint + if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 { + continue + } + notif := struct { + RecipientID uint + SenderID uint + Type string + TargetID uint + Content string + }{ + RecipientID: accID, + SenderID: comment.AuthorID, + Type: "mention", + TargetID: comment.VideoID, + Content: comment.Username + " 在评论中提到了你", + } + s.repo.db.WithContext(ctx).Table("notifications").Create(¬if) + } +} diff --git a/backend/internal/video/like_handler.go b/backend/internal/video/like_handler.go index 8946438..3f35910 100644 --- a/backend/internal/video/like_handler.go +++ b/backend/internal/video/like_handler.go @@ -1,114 +1,114 @@ -package video - -import ( - "feedsystem_video_go/internal/middleware/jwt" - "feedsystem_video_go/internal/apierror" - - "github.com/gin-gonic/gin" -) - -type LikeHandler struct { - service *LikeService -} - -func NewLikeHandler(service *LikeService) *LikeHandler { - return &LikeHandler{service: service} -} - -func (lh *LikeHandler) Like(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - like := &Like{ - VideoID: req.VideoID, - AccountID: accountID, - } - if err := lh.service.Like(c.Request.Context(), like); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "like success"}) -} - -func (lh *LikeHandler) Unlike(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - like := &Like{ - VideoID: req.VideoID, - AccountID: accountID, - } - if err := lh.service.Unlike(c.Request.Context(), like); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "unlike success"}) -} - -func (lh *LikeHandler) IsLiked(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"is_liked": isLiked}) -} - -func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - if videos == nil { - videos = []Video{} - } - c.JSON(200, videos) -} +package video + +import ( + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/jwt" + + "github.com/gin-gonic/gin" +) + +type LikeHandler struct { + service *LikeService +} + +func NewLikeHandler(service *LikeService) *LikeHandler { + return &LikeHandler{service: service} +} + +func (lh *LikeHandler) Like(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + like := &Like{ + VideoID: req.VideoID, + AccountID: accountID, + } + if err := lh.service.Like(c.Request.Context(), like); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "like success"}) +} + +func (lh *LikeHandler) Unlike(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + like := &Like{ + VideoID: req.VideoID, + AccountID: accountID, + } + if err := lh.service.Unlike(c.Request.Context(), like); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "unlike success"}) +} + +func (lh *LikeHandler) IsLiked(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"is_liked": isLiked}) +} + +func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + if videos == nil { + videos = []Video{} + } + c.JSON(200, videos) +} diff --git a/backend/internal/video/like_repo.go b/backend/internal/video/like_repo.go index c17ec96..c8d1a76 100644 --- a/backend/internal/video/like_repo.go +++ b/backend/internal/video/like_repo.go @@ -1,102 +1,102 @@ -package video - -import ( - "context" - "errors" - - "github.com/go-sql-driver/mysql" - "gorm.io/gorm" -) - -type LikeRepository struct { - db *gorm.DB -} - -func NewLikeRepository(db *gorm.DB) *LikeRepository { - return &LikeRepository{db: db} -} - -func (r *LikeRepository) Like(ctx context.Context, like *Like) error { - return r.db.WithContext(ctx).Create(like).Error -} - -func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error { - return r.db.WithContext(ctx). - Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID). - Delete(&Like{}).Error -} - -func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) { - if like == nil || like.VideoID == 0 || like.AccountID == 0 { - return false, nil - } - err = r.db.WithContext(ctx).Create(like).Error - if err == nil { - return true, nil - } - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return false, nil - } - return false, err -} - -func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) { - if videoID == 0 || accountID == 0 { - return false, nil - } - res := r.db.WithContext(ctx). - Where("video_id = ? AND account_id = ?", videoID, accountID). - Delete(&Like{}) - return res.RowsAffected > 0, res.Error -} - -func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) { - var count int64 - err := r.db.WithContext(ctx).Model(&Like{}). - Where("video_id = ? AND account_id = ?", videoID, accountID). - Count(&count).Error - if err != nil { - return false, err - } - return count > 0, nil -} - -func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) { - likeMap := make(map[uint]bool) - if len(videoIDs) == 0 { - return likeMap, nil - } - if accountID == 0 { - return likeMap, nil - } - var likes []Like - err := r.db.WithContext(ctx).Model(&Like{}). - Where("video_id IN ? AND account_id = ?", videoIDs, accountID). - Find(&likes).Error - if err != nil { - return nil, err - } - for _, like := range likes { - likeMap[like.VideoID] = true - } - return likeMap, nil -} - -func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) { - var videos []Video - if accountID == 0 { - return videos, nil - } - err := r.db.WithContext(ctx). - Model(&Video{}). - Joins("JOIN likes ON likes.video_id = videos.id"). - Where("likes.account_id = ?", accountID). - Order("likes.created_at desc"). - Limit(200). - Find(&videos).Error - if err != nil { - return nil, err - } - return videos, nil -} +package video + +import ( + "context" + "errors" + + "github.com/go-sql-driver/mysql" + "gorm.io/gorm" +) + +type LikeRepository struct { + db *gorm.DB +} + +func NewLikeRepository(db *gorm.DB) *LikeRepository { + return &LikeRepository{db: db} +} + +func (r *LikeRepository) Like(ctx context.Context, like *Like) error { + return r.db.WithContext(ctx).Create(like).Error +} + +func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error { + return r.db.WithContext(ctx). + Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID). + Delete(&Like{}).Error +} + +func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) { + if like == nil || like.VideoID == 0 || like.AccountID == 0 { + return false, nil + } + err = r.db.WithContext(ctx).Create(like).Error + if err == nil { + return true, nil + } + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return false, nil + } + return false, err +} + +func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) { + if videoID == 0 || accountID == 0 { + return false, nil + } + res := r.db.WithContext(ctx). + Where("video_id = ? AND account_id = ?", videoID, accountID). + Delete(&Like{}) + return res.RowsAffected > 0, res.Error +} + +func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&Like{}). + Where("video_id = ? AND account_id = ?", videoID, accountID). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) { + likeMap := make(map[uint]bool) + if len(videoIDs) == 0 { + return likeMap, nil + } + if accountID == 0 { + return likeMap, nil + } + var likes []Like + err := r.db.WithContext(ctx).Model(&Like{}). + Where("video_id IN ? AND account_id = ?", videoIDs, accountID). + Find(&likes).Error + if err != nil { + return nil, err + } + for _, like := range likes { + likeMap[like.VideoID] = true + } + return likeMap, nil +} + +func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) { + var videos []Video + if accountID == 0 { + return videos, nil + } + err := r.db.WithContext(ctx). + Model(&Video{}). + Joins("JOIN likes ON likes.video_id = videos.id"). + Where("likes.account_id = ?", accountID). + Order("likes.created_at desc"). + Limit(200). + Find(&videos).Error + if err != nil { + return nil, err + } + return videos, nil +} diff --git a/backend/internal/video/popularity_cache.go b/backend/internal/video/popularity_cache.go index 62b4dc5..b3ffaae 100644 --- a/backend/internal/video/popularity_cache.go +++ b/backend/internal/video/popularity_cache.go @@ -1,28 +1,28 @@ -package video - -import ( - "context" - "strconv" - "time" - - rediscache "feedsystem_video_go/internal/middleware/redis" -) - -// 更新视频流行度缓存 -func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { - if cache == nil || id == 0 || change == 0 { - return - } - - _ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id)) - - now := time.Now().UTC().Truncate(time.Minute) - windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504")) - member := strconv.FormatUint(uint64(id), 10) - - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - _ = cache.ZincrBy(opCtx, windowKey, member, float64(change)) - _ = cache.Expire(opCtx, windowKey, 2*time.Hour) -} +package video + +import ( + "context" + "strconv" + "time" + + rediscache "feedsystem_video_go/internal/middleware/redis" +) + +// 更新视频流行度缓存 +func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { + if cache == nil || id == 0 || change == 0 { + return + } + + _ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id)) + + now := time.Now().UTC().Truncate(time.Minute) + windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504")) + member := strconv.FormatUint(uint64(id), 10) + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + _ = cache.ZincrBy(opCtx, windowKey, member, float64(change)) + _ = cache.Expire(opCtx, windowKey, 2*time.Hour) +} diff --git a/backend/internal/video/video_entity.go b/backend/internal/video/video_entity.go index d0d0b5a..5ea8e9a 100644 --- a/backend/internal/video/video_entity.go +++ b/backend/internal/video/video_entity.go @@ -1,48 +1,48 @@ -package video - -import "time" - -type Video struct { - ID uint `gorm:"primaryKey" json:"id"` - AuthorID uint `gorm:"index;not null" json:"author_id"` - Username string `gorm:"type:varchar(255);not null" json:"username"` - Title string `gorm:"type:varchar(255);not null" json:"title"` - Description string `gorm:"type:varchar(255);" json:"description,omitempty"` - PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"` - CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` - CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"` - LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"` - Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"` -} - -type PublishVideoRequest struct { - Title string `json:"title"` - Description string `json:"description"` - PlayURL string `json:"play_url"` - CoverURL string `json:"cover_url"` -} - -type DeleteVideoRequest struct { - ID uint `json:"id"` -} - -type ListByAuthorIDRequest struct { - AuthorID uint `json:"author_id"` -} - -type GetDetailRequest struct { - ID uint `json:"id"` -} - -type UpdateLikesCountRequest struct { - ID uint `json:"id"` - LikesCount int64 `json:"likes_count"` -} - -type OutboxMsg struct { - ID uint `gorm:"primaryKey"` - VideoID uint `gorm:"index"` - EventType string `gorm:"type:varchar(50)"` - CreateTime time.Time `gorm:"autoCreateTime"` - Status string `gorm:"type:varchar(50);index"` -} +package video + +import "time" + +type Video struct { + ID uint `gorm:"primaryKey" json:"id"` + AuthorID uint `gorm:"index;not null" json:"author_id"` + Username string `gorm:"type:varchar(255);not null" json:"username"` + Title string `gorm:"type:varchar(255);not null" json:"title"` + Description string `gorm:"type:varchar(255);" json:"description,omitempty"` + PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"` + CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` + CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"` + LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"` + Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"` +} + +type PublishVideoRequest struct { + Title string `json:"title"` + Description string `json:"description"` + PlayURL string `json:"play_url"` + CoverURL string `json:"cover_url"` +} + +type DeleteVideoRequest struct { + ID uint `json:"id"` +} + +type ListByAuthorIDRequest struct { + AuthorID uint `json:"author_id"` +} + +type GetDetailRequest struct { + ID uint `json:"id"` +} + +type UpdateLikesCountRequest struct { + ID uint `json:"id"` + LikesCount int64 `json:"likes_count"` +} + +type OutboxMsg struct { + ID uint `gorm:"primaryKey"` + VideoID uint `gorm:"index"` + EventType string `gorm:"type:varchar(50)"` + CreateTime time.Time `gorm:"autoCreateTime"` + Status string `gorm:"type:varchar(50);index"` +} diff --git a/backend/internal/video/video_handler.go b/backend/internal/video/video_handler.go index ac449c8..7f09964 100644 --- a/backend/internal/video/video_handler.go +++ b/backend/internal/video/video_handler.go @@ -1,254 +1,254 @@ -package video - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "net/http" - "os" - "path" - "path/filepath" - "strings" - "time" - - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/apierror" - "feedsystem_video_go/internal/middleware/jwt" - - "github.com/gin-gonic/gin" -) - -type VideoHandler struct { - service *VideoService - accountService *account.AccountService -} - -func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler { - return &VideoHandler{service: service, accountService: accountService} -} - -func (vh *VideoHandler) PublishVideo(c *gin.Context) { - var req PublishVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - username, err := jwt.GetUsername(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - video := &Video{ - AuthorID: authorId, - Username: username, - Title: req.Title, - Description: req.Description, - PlayURL: req.PlayURL, - CoverURL: req.CoverURL, - CreateTime: time.Now(), - } - if err := vh.service.Publish(c.Request.Context(), video); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, video) -} - -func (vh *VideoHandler) UploadVideo(c *gin.Context) { - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - f, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) - return - } - - const maxSize = 200 << 20 - if f.Size <= 0 || f.Size > maxSize { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) - return - } - - ext := strings.ToLower(filepath.Ext(f.Filename)) - if ext != ".mp4" { - c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"}) - return - } - - date := time.Now().Format("20060102") - relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date) - root := filepath.Join(".run", "uploads") - absDir := filepath.Join(root, relDir) - if err := os.MkdirAll(absDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - filename, err := randHex(16) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) - return - } - filename = filename + ext - absPath := filepath.Join(absDir, filename) - - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename) - - c.JSON(http.StatusOK, gin.H{ - "url": buildAbsoluteURL(c, urlPath), - "play_url": buildAbsoluteURL(c, urlPath), - }) -} - -func (vh *VideoHandler) UploadCover(c *gin.Context) { - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - f, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) - return - } - - const maxSize = 10 << 20 - if f.Size <= 0 || f.Size > maxSize { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) - return - } - - ext := strings.ToLower(filepath.Ext(f.Filename)) - switch ext { - case ".jpg", ".jpeg", ".png", ".webp": - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"}) - return - } - - date := time.Now().Format("20060102") - relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date) - root := filepath.Join(".run", "uploads") - absDir := filepath.Join(root, relDir) - if err := os.MkdirAll(absDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - filename, err := randHex(16) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) - return - } - filename = filename + ext - absPath := filepath.Join(absDir, filename) - - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename) - - c.JSON(http.StatusOK, gin.H{ - "url": buildAbsoluteURL(c, urlPath), - "cover_url": buildAbsoluteURL(c, urlPath), - }) -} - -func randHex(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("rand.Read: %w", err) - } - return hex.EncodeToString(b), nil -} - -func buildAbsoluteURL(c *gin.Context, p string) string { - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { - scheme = xf - } - return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) -} - -func (vh *VideoHandler) DeleteVideo(c *gin.Context) { - var req DeleteVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "video deleted"}) -} - -func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { - var req ListByAuthorIDRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if videos == nil { - videos = []Video{} - } - c.JSON(200, videos) -} - -func (vh *VideoHandler) GetDetail(c *gin.Context) { - var req GetDetailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - video, err := vh.service.GetDetail(c.Request.Context(), req.ID) - if err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, video) -} - -func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { - var req UpdateLikesCountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "likes count updated"}) -} +package video + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/jwt" + + "github.com/gin-gonic/gin" +) + +type VideoHandler struct { + service *VideoService + accountService *account.AccountService +} + +func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler { + return &VideoHandler{service: service, accountService: accountService} +} + +func (vh *VideoHandler) PublishVideo(c *gin.Context) { + var req PublishVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + username, err := jwt.GetUsername(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + video := &Video{ + AuthorID: authorId, + Username: username, + Title: req.Title, + Description: req.Description, + PlayURL: req.PlayURL, + CoverURL: req.CoverURL, + CreateTime: time.Now(), + } + if err := vh.service.Publish(c.Request.Context(), video); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, video) +} + +func (vh *VideoHandler) UploadVideo(c *gin.Context) { + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + + const maxSize = 200 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + + ext := strings.ToLower(filepath.Ext(f.Filename)) + if ext != ".mp4" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"}) + return + } + + date := time.Now().Format("20060102") + relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date) + root := filepath.Join(".run", "uploads") + absDir := filepath.Join(root, relDir) + if err := os.MkdirAll(absDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) + return + } + filename = filename + ext + absPath := filepath.Join(absDir, filename) + + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename) + + c.JSON(http.StatusOK, gin.H{ + "url": buildAbsoluteURL(c, urlPath), + "play_url": buildAbsoluteURL(c, urlPath), + }) +} + +func (vh *VideoHandler) UploadCover(c *gin.Context) { + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + + const maxSize = 10 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + + ext := strings.ToLower(filepath.Ext(f.Filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"}) + return + } + + date := time.Now().Format("20060102") + relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date) + root := filepath.Join(".run", "uploads") + absDir := filepath.Join(root, relDir) + if err := os.MkdirAll(absDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) + return + } + filename = filename + ext + absPath := filepath.Join(absDir, filename) + + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename) + + c.JSON(http.StatusOK, gin.H{ + "url": buildAbsoluteURL(c, urlPath), + "cover_url": buildAbsoluteURL(c, urlPath), + }) +} + +func randHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(b), nil +} + +func buildAbsoluteURL(c *gin.Context, p string) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { + scheme = xf + } + return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) +} + +func (vh *VideoHandler) DeleteVideo(c *gin.Context) { + var req DeleteVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "video deleted"}) +} + +func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { + var req ListByAuthorIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if videos == nil { + videos = []Video{} + } + c.JSON(200, videos) +} + +func (vh *VideoHandler) GetDetail(c *gin.Context) { + var req GetDetailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + video, err := vh.service.GetDetail(c.Request.Context(), req.ID) + if err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, video) +} + +func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { + var req UpdateLikesCountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { + c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "likes count updated"}) +} diff --git a/backend/internal/video/video_repo.go b/backend/internal/video/video_repo.go index 9dce48f..c11c1b6 100644 --- a/backend/internal/video/video_repo.go +++ b/backend/internal/video/video_repo.go @@ -1,120 +1,120 @@ -package video - -import ( - "context" - "errors" - - "gorm.io/gorm" -) - -type VideoRepository struct { - db *gorm.DB -} - -func NewVideoRepository(db *gorm.DB) *VideoRepository { - return &VideoRepository{db: db} -} - -func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error { - if err := vr.db.WithContext(ctx).Create(video).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error { - if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error { - if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) { - var videos []Video - if err := vr.db.WithContext(ctx). - Where("author_id = ?", authorID). - Order("create_time desc"). - Limit(200). - Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) { - var video Video - if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { - return (*Video)(nil), err - } - return &video, nil -} - -func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - Update("likes_count", likesCount).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) { - var video Video - if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return true, nil -} - -func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) { - var count int64 - if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) { - var total int64 - if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil { - return 0, err - } - return total, nil -} +package video + +import ( + "context" + "errors" + + "gorm.io/gorm" +) + +type VideoRepository struct { + db *gorm.DB +} + +func NewVideoRepository(db *gorm.DB) *VideoRepository { + return &VideoRepository{db: db} +} + +func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error { + if err := vr.db.WithContext(ctx).Create(video).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error { + if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error { + if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) { + var videos []Video + if err := vr.db.WithContext(ctx). + Where("author_id = ?", authorID). + Order("create_time desc"). + Limit(200). + Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) { + var video Video + if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { + return (*Video)(nil), err + } + return &video, nil +} + +func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + Update("likes_count", likesCount).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) { + var video Video + if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + +func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) { + var count int64 + if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) { + var total int64 + if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil +} diff --git a/backend/internal/video/video_service.go b/backend/internal/video/video_service.go index ea35550..e387695 100644 --- a/backend/internal/video/video_service.go +++ b/backend/internal/video/video_service.go @@ -1,226 +1,226 @@ -package video - -import ( - "context" - "encoding/json" - "errors" - "strconv" - "strings" - "time" - - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/apierror" - - "gorm.io/gorm" -) - -type VideoService struct { - repo *VideoRepository - cache *rediscache.Client - cacheTTL time.Duration - popularityMQ *rabbitmq.PopularityMQ -} - -func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService { - return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ} -} - -func (vs *VideoService) Publish(ctx context.Context, video *Video) error { - if video == nil { - return errors.New("video is nil") - } - video.Title = strings.TrimSpace(video.Title) - video.PlayURL = strings.TrimSpace(video.PlayURL) - video.CoverURL = strings.TrimSpace(video.CoverURL) - - if video.Title == "" { - return errors.New("title is required") - } - if video.PlayURL == "" { - return errors.New("play url is required") - } - if video.CoverURL == "" { - return errors.New("cover url is required") - } - - //事务保证视频写入库和消息写入本地消息表的一致性 - err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(video).Error; err != nil { - return err - } - - msg := OutboxMsg{ - VideoID: video.ID, - EventType: "video_published", - Status: "pending", - CreateTime: video.CreateTime, - } - - if err := tx.Create(&msg).Error; err != nil { - return err - } - - tags := ExtractTags(video.Title + " " + video.Description) - for _, tagName := range tags { - var tag Tag - tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName}) - tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID}) - } - return nil - }) - return err - -} - -func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error { - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return err - } - if video == nil { - return errors.New("video not found") - } - if video.AuthorID != authorID { - return apierror.ErrUnauthorized - } - if err := vs.repo.DeleteVideo(ctx, id); err != nil { - return err - } - if vs.cache != nil { - cacheKey := vs.cache.Key("video:detail:id=%d", id) - _ = vs.cache.Del(context.Background(), cacheKey) - } - return nil -} - -func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) { - videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID)) - if err != nil { - return nil, err - } - return videos, nil -} - -func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { - cacheKey := vs.cache.Key("video:detail:id=%d", id) - - getCached := func() (*Video, bool) { - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - b, err := vs.cache.GetBytes(opCtx, cacheKey) - if err != nil { - return nil, false - } - var cached Video - if err := json.Unmarshal(b, &cached); err != nil { - return nil, false - } - return &cached, true - } - - setCached := func(video *Video) { - b, err := json.Marshal(video) - if err != nil { - return - } - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) - } - - if vs.cache != nil { - if v, ok := getCached(); ok { - return v, nil - } - - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - b, err := vs.cache.GetBytes(opCtx, cacheKey) - cancel() - if err == nil { - var cached Video - if err := json.Unmarshal(b, &cached); err == nil { - return &cached, nil - } - } else if rediscache.IsMiss(err) { - lockKey := "lock:" + cacheKey - - lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond) - token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) - lockCancel() - - if lockErr == nil && locked { - defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() - - if v, ok := getCached(); ok { - return v, nil - } - - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - setCached(video) - return video, nil - } - - // 没拿到锁:等待别人回填缓存 - for i := 0; i < 5; i++ { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } - if v, ok := getCached(); ok { - return v, nil - } - } - } - } - - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if vs.cache != nil { - setCached(video) - } - return video, nil -} - -func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { - if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil { - return err - } - return nil -} - -func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error { - if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { - return err - } - - if vs.popularityMQ != nil { - if err := vs.popularityMQ.Update(ctx, id, change); err == nil { - return nil - } - } - - if vs.cache != nil { - // 1) 详情缓存:直接失效(最简单靠谱) - _ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id)) - - // 2) 热榜:写到“时间窗ZSET”,不要用 detail key - now := time.Now().UTC().Truncate(time.Minute) - windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504")) - member := strconv.FormatUint(uint64(id), 10) - - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change)) - _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour) - } - return nil -} +package video + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "feedsystem_video_go/internal/apierror" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + + "gorm.io/gorm" +) + +type VideoService struct { + repo *VideoRepository + cache *rediscache.Client + cacheTTL time.Duration + popularityMQ *rabbitmq.PopularityMQ +} + +func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService { + return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ} +} + +func (vs *VideoService) Publish(ctx context.Context, video *Video) error { + if video == nil { + return errors.New("video is nil") + } + video.Title = strings.TrimSpace(video.Title) + video.PlayURL = strings.TrimSpace(video.PlayURL) + video.CoverURL = strings.TrimSpace(video.CoverURL) + + if video.Title == "" { + return errors.New("title is required") + } + if video.PlayURL == "" { + return errors.New("play url is required") + } + if video.CoverURL == "" { + return errors.New("cover url is required") + } + + //事务保证视频写入库和消息写入本地消息表的一致性 + err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(video).Error; err != nil { + return err + } + + msg := OutboxMsg{ + VideoID: video.ID, + EventType: "video_published", + Status: "pending", + CreateTime: video.CreateTime, + } + + if err := tx.Create(&msg).Error; err != nil { + return err + } + + tags := ExtractTags(video.Title + " " + video.Description) + for _, tagName := range tags { + var tag Tag + tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName}) + tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID}) + } + return nil + }) + return err + +} + +func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error { + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return err + } + if video == nil { + return errors.New("video not found") + } + if video.AuthorID != authorID { + return apierror.ErrUnauthorized + } + if err := vs.repo.DeleteVideo(ctx, id); err != nil { + return err + } + if vs.cache != nil { + cacheKey := vs.cache.Key("video:detail:id=%d", id) + _ = vs.cache.Del(context.Background(), cacheKey) + } + return nil +} + +func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) { + videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID)) + if err != nil { + return nil, err + } + return videos, nil +} + +func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { + cacheKey := vs.cache.Key("video:detail:id=%d", id) + + getCached := func() (*Video, bool) { + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + b, err := vs.cache.GetBytes(opCtx, cacheKey) + if err != nil { + return nil, false + } + var cached Video + if err := json.Unmarshal(b, &cached); err != nil { + return nil, false + } + return &cached, true + } + + setCached := func(video *Video) { + b, err := json.Marshal(video) + if err != nil { + return + } + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) + } + + if vs.cache != nil { + if v, ok := getCached(); ok { + return v, nil + } + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + b, err := vs.cache.GetBytes(opCtx, cacheKey) + cancel() + if err == nil { + var cached Video + if err := json.Unmarshal(b, &cached); err == nil { + return &cached, nil + } + } else if rediscache.IsMiss(err) { + lockKey := "lock:" + cacheKey + + lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond) + token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) + lockCancel() + + if lockErr == nil && locked { + defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() + + if v, ok := getCached(); ok { + return v, nil + } + + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + setCached(video) + return video, nil + } + + // 没拿到锁:等待别人回填缓存 + for i := 0; i < 5; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + if v, ok := getCached(); ok { + return v, nil + } + } + } + } + + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if vs.cache != nil { + setCached(video) + } + return video, nil +} + +func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { + if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil { + return err + } + return nil +} + +func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { + return err + } + + if vs.popularityMQ != nil { + if err := vs.popularityMQ.Update(ctx, id, change); err == nil { + return nil + } + } + + if vs.cache != nil { + // 1) 详情缓存:直接失效(最简单靠谱) + _ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id)) + + // 2) 热榜:写到“时间窗ZSET”,不要用 detail key + now := time.Now().UTC().Truncate(time.Minute) + windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504")) + member := strconv.FormatUint(uint64(id), 10) + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change)) + _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour) + } + return nil +} diff --git a/backend/internal/worker/commentworker.go b/backend/internal/worker/commentworker.go index 3fdde7f..dbfdd3d 100644 --- a/backend/internal/worker/commentworker.go +++ b/backend/internal/worker/commentworker.go @@ -1,128 +1,127 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/video" - "log" - "strings" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type CommentWorker struct { - ch *amqp.Channel - comments *video.CommentRepository - videos *video.VideoRepository - queue string -} - -func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker { - return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue} -} - -func (w *CommentWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.comments == nil || w.videos == nil { - return errors.New("comment worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - retryCount := rabbitmq.GetRetryCount(d) - if retryCount >= rabbitmq.MaxRetryCount { - log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) - _ = d.Ack(false) - return - } - log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *CommentWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.CommentEvent - if err := json.Unmarshal(body, &evt); err != nil { - return nil - } - switch evt.Action { - case "publish": - return w.applyPublish(ctx, &evt) - case "delete": - return w.applyDelete(ctx, &evt) - default: - return nil - } -} - -func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error { - if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" { - return nil - } - - ok, err := w.videos.IsExist(ctx, evt.VideoID) - if err != nil { - return err - } - if !ok { - return nil - } - - c := &video.Comment{ - Username: strings.TrimSpace(evt.Username), - VideoID: evt.VideoID, - AuthorID: evt.AuthorID, - Content: strings.TrimSpace(evt.Content), - } - if err := w.comments.CreateComment(ctx, c); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, evt.VideoID, 1) -} - -func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error { - if evt == nil || evt.CommentID == 0 { - return nil - } - c, err := w.comments.GetByID(ctx, evt.CommentID) - if err != nil { - return err - } - if c == nil { - return nil - } - return w.comments.DeleteComment(ctx, c) -} - +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/video" + "log" + "strings" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type CommentWorker struct { + ch *amqp.Channel + comments *video.CommentRepository + videos *video.VideoRepository + queue string +} + +func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker { + return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue} +} + +func (w *CommentWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.comments == nil || w.videos == nil { + return errors.New("comment worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { + if err := w.process(ctx, d.Body); err != nil { + retryCount := rabbitmq.GetRetryCount(d) + if retryCount >= rabbitmq.MaxRetryCount { + log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) + _ = d.Ack(false) + return + } + log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *CommentWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.CommentEvent + if err := json.Unmarshal(body, &evt); err != nil { + return nil + } + switch evt.Action { + case "publish": + return w.applyPublish(ctx, &evt) + case "delete": + return w.applyDelete(ctx, &evt) + default: + return nil + } +} + +func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error { + if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" { + return nil + } + + ok, err := w.videos.IsExist(ctx, evt.VideoID) + if err != nil { + return err + } + if !ok { + return nil + } + + c := &video.Comment{ + Username: strings.TrimSpace(evt.Username), + VideoID: evt.VideoID, + AuthorID: evt.AuthorID, + Content: strings.TrimSpace(evt.Content), + } + if err := w.comments.CreateComment(ctx, c); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, evt.VideoID, 1) +} + +func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error { + if evt == nil || evt.CommentID == 0 { + return nil + } + c, err := w.comments.GetByID(ctx, evt.CommentID) + if err != nil { + return err + } + if c == nil { + return nil + } + return w.comments.DeleteComment(ctx, c) +} diff --git a/backend/internal/worker/likeworker.go b/backend/internal/worker/likeworker.go index ae60369..dfe8826 100644 --- a/backend/internal/worker/likeworker.go +++ b/backend/internal/worker/likeworker.go @@ -1,142 +1,142 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/video" - "log" - amqp "github.com/rabbitmq/amqp091-go" - "time" -) - -type LikeWorker struct { - ch *amqp.Channel - likes *video.LikeRepository - videos *video.VideoRepository - queue string -} - -func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker { - return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue} -} - -func (w *LikeWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.likes == nil || w.videos == nil { - return errors.New("like worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - retryCount := rabbitmq.GetRetryCount(d) - if retryCount >= rabbitmq.MaxRetryCount { - log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) - _ = d.Ack(false) - return - } - log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *LikeWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.LikeEvent - if err := json.Unmarshal(body, &evt); err != nil { - // 解析事件失败,直接丢弃 - return nil - } - if evt.UserID == 0 || evt.VideoID == 0 { - return nil - } - - switch evt.Action { - case "like": - return w.applyLike(ctx, evt.UserID, evt.VideoID) - case "unlike": - return w.applyUnlike(ctx, evt.UserID, evt.VideoID) - default: - return nil - } -} - -func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error { - ok, err := w.videos.IsExist(ctx, videoID) - if err != nil { - return err - } - if !ok { - return nil - } - - created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{ - VideoID: videoID, - AccountID: userID, - CreatedAt: time.Now(), - }) - if err != nil { - return err - } - if !created { - return nil - } - - if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, videoID, 1) -} - -func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error { - ok, err := w.videos.IsExist(ctx, videoID) - if err != nil { - return err - } - if !ok { - return nil - } - - deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID) - if err != nil { - return err - } - if !deleted { - return nil - } - - if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, videoID, -1) -} +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/video" + amqp "github.com/rabbitmq/amqp091-go" + "log" + "time" +) + +type LikeWorker struct { + ch *amqp.Channel + likes *video.LikeRepository + videos *video.VideoRepository + queue string +} + +func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker { + return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue} +} + +func (w *LikeWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.likes == nil || w.videos == nil { + return errors.New("like worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { + if err := w.process(ctx, d.Body); err != nil { + retryCount := rabbitmq.GetRetryCount(d) + if retryCount >= rabbitmq.MaxRetryCount { + log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) + _ = d.Ack(false) + return + } + log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *LikeWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.LikeEvent + if err := json.Unmarshal(body, &evt); err != nil { + // 解析事件失败,直接丢弃 + return nil + } + if evt.UserID == 0 || evt.VideoID == 0 { + return nil + } + + switch evt.Action { + case "like": + return w.applyLike(ctx, evt.UserID, evt.VideoID) + case "unlike": + return w.applyUnlike(ctx, evt.UserID, evt.VideoID) + default: + return nil + } +} + +func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error { + ok, err := w.videos.IsExist(ctx, videoID) + if err != nil { + return err + } + if !ok { + return nil + } + + created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{ + VideoID: videoID, + AccountID: userID, + CreatedAt: time.Now(), + }) + if err != nil { + return err + } + if !created { + return nil + } + + if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, videoID, 1) +} + +func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error { + ok, err := w.videos.IsExist(ctx, videoID) + if err != nil { + return err + } + if !ok { + return nil + } + + deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID) + if err != nil { + return err + } + if !deleted { + return nil + } + + if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, videoID, -1) +} diff --git a/backend/internal/worker/notificationworker.go b/backend/internal/worker/notificationworker.go index 08fa767..2072f56 100644 --- a/backend/internal/worker/notificationworker.go +++ b/backend/internal/worker/notificationworker.go @@ -13,21 +13,21 @@ import ( ) type Notification struct { - ID uint `gorm:"primaryKey" json:"id"` - RecipientID uint `gorm:"index;not null" json:"recipient_id"` - SenderID uint `gorm:"not null" json:"sender_id"` - Type string `gorm:"type:varchar(50);not null" json:"type"` - TargetID uint `json:"target_id"` - Content string `gorm:"type:varchar(255)" json:"content"` - IsRead bool `gorm:"default:false" json:"is_read"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uint `gorm:"primaryKey" json:"id"` + RecipientID uint `gorm:"index;not null" json:"recipient_id"` + SenderID uint `gorm:"not null" json:"sender_id"` + Type string `gorm:"type:varchar(50);not null" json:"type"` + TargetID uint `json:"target_id"` + Content string `gorm:"type:varchar(255)" json:"content"` + IsRead bool `gorm:"default:false" json:"is_read"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` } type NotificationWorker struct { - ch *amqp.Channel - db *gorm.DB - queue string - hub NotificationHub + ch *amqp.Channel + db *gorm.DB + queue string + hub NotificationHub } type NotificationHub interface { @@ -96,7 +96,10 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error return nil } var authorID uint - w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID) + w.db.WithContext(ctx).Model(&struct { + ID uint + AuthorID uint + }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID) if authorID == 0 || authorID == evt.UserID { return nil } @@ -111,7 +114,10 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error return nil } var authorID uint - w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID) + w.db.WithContext(ctx).Model(&struct { + ID uint + AuthorID uint + }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID) if authorID == 0 || authorID == evt.AuthorID { return nil } diff --git a/backend/internal/worker/popularityworker.go b/backend/internal/worker/popularityworker.go index 8f1fbaf..bfa7b21 100644 --- a/backend/internal/worker/popularityworker.go +++ b/backend/internal/worker/popularityworker.go @@ -1,85 +1,84 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/video" - "log" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type PopularityWorker struct { - ch *amqp.Channel - cache *rediscache.Client - queue string -} - -func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker { - return &PopularityWorker{ch: ch, cache: cache, queue: queue} -} - -func (w *PopularityWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.cache == nil { - return errors.New("popularity worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - retryCount := rabbitmq.GetRetryCount(d) - if retryCount >= rabbitmq.MaxRetryCount { - log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) - _ = d.Ack(false) - return - } - log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *PopularityWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.PopularityEvent - if err := json.Unmarshal(body, &evt); err != nil { - return nil - } - if evt.VideoID == 0 || evt.Change == 0 { - return nil - } - video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change) - return nil -} - +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/video" + "log" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type PopularityWorker struct { + ch *amqp.Channel + cache *rediscache.Client + queue string +} + +func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker { + return &PopularityWorker{ch: ch, cache: cache, queue: queue} +} + +func (w *PopularityWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.cache == nil { + return errors.New("popularity worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { + if err := w.process(ctx, d.Body); err != nil { + retryCount := rabbitmq.GetRetryCount(d) + if retryCount >= rabbitmq.MaxRetryCount { + log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) + _ = d.Ack(false) + return + } + log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *PopularityWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.PopularityEvent + if err := json.Unmarshal(body, &evt); err != nil { + return nil + } + if evt.VideoID == 0 || evt.Change == 0 { + return nil + } + video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change) + return nil +} diff --git a/backend/internal/worker/socialworker.go b/backend/internal/worker/socialworker.go index 2aff6ac..923f0c6 100644 --- a/backend/internal/worker/socialworker.go +++ b/backend/internal/worker/socialworker.go @@ -1,106 +1,106 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/social" - "log" - - "github.com/go-sql-driver/mysql" - amqp "github.com/rabbitmq/amqp091-go" -) - -type SocialWorker struct { - ch *amqp.Channel - repo *social.SocialRepository - queue string -} - -func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker { - return &SocialWorker{ch: ch, repo: repo, queue: queue} -} - -func (w *SocialWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.repo == nil { - return errors.New("social worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - retryCount := rabbitmq.GetRetryCount(d) - if retryCount >= rabbitmq.MaxRetryCount { - log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) - _ = d.Ack(false) - return - } - log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *SocialWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.SocialEvent - if err := json.Unmarshal(body, &evt); err != nil { - // 解析事件失败,直接丢弃 - return nil - } - if evt.FollowerID == 0 || evt.VloggerID == 0 { - return nil - } - - switch evt.Action { - case "follow": - err := w.repo.Follow(ctx, &social.Social{ - FollowerID: evt.FollowerID, - VloggerID: evt.VloggerID, - }) - if err == nil { - return nil - } - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return nil - } - return err - case "unfollow": - return w.repo.Unfollow(ctx, &social.Social{ - FollowerID: evt.FollowerID, - VloggerID: evt.VloggerID, - }) - default: - return nil - } -} +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/social" + "log" + + "github.com/go-sql-driver/mysql" + amqp "github.com/rabbitmq/amqp091-go" +) + +type SocialWorker struct { + ch *amqp.Channel + repo *social.SocialRepository + queue string +} + +func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker { + return &SocialWorker{ch: ch, repo: repo, queue: queue} +} + +func (w *SocialWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.repo == nil { + return errors.New("social worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { + if err := w.process(ctx, d.Body); err != nil { + retryCount := rabbitmq.GetRetryCount(d) + if retryCount >= rabbitmq.MaxRetryCount { + log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err) + _ = d.Ack(false) + return + } + log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *SocialWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.SocialEvent + if err := json.Unmarshal(body, &evt); err != nil { + // 解析事件失败,直接丢弃 + return nil + } + if evt.FollowerID == 0 || evt.VloggerID == 0 { + return nil + } + + switch evt.Action { + case "follow": + err := w.repo.Follow(ctx, &social.Social{ + FollowerID: evt.FollowerID, + VloggerID: evt.VloggerID, + }) + if err == nil { + return nil + } + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return nil + } + return err + case "unfollow": + return w.repo.Unfollow(ctx, &social.Social{ + FollowerID: evt.FollowerID, + VloggerID: evt.VloggerID, + }) + default: + return nil + } +} diff --git a/backend/internal/worker/ssehub.go b/backend/internal/worker/ssehub.go index 332ee6d..ca855d9 100644 --- a/backend/internal/worker/ssehub.go +++ b/backend/internal/worker/ssehub.go @@ -14,9 +14,9 @@ import ( ) type SSEHub struct { - mu sync.RWMutex - clients map[uint][]chan *Notification - db *gorm.DB + mu sync.RWMutex + clients map[uint][]chan *Notification + db *gorm.DB } func NewSSEHub(db *gorm.DB) *SSEHub { diff --git a/docker-compose.yml b/docker-compose.yml index 6702c23..26a7553 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,64 +1,64 @@ services: - mysql: - image: mysql:8.0 - restart: always - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456} - MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} - TZ: "Asia/Shanghai" - ports: - - "3307:3306" - volumes: - - mysql_data:/var/lib/mysql - command: - - --default-authentication-plugin=mysql_native_password - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_0900_ai_ci + mysql: + image: mysql:8.0 + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456} + MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} + TZ: "Asia/Shanghai" + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] - interval: 5s - timeout: 5s - retries: 20 - + interval: 5s + timeout: 5s + retries: 20 + redis: image: redis:7-alpine restart: always environment: REDIS_PASSWORD: ${REDIS_PASSWORD:-123456} command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"] - ports: - - "6379:6379" - volumes: - - redis_data:/data + ports: + - "6379:6379" + volumes: + - redis_data:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"] - interval: 5s - timeout: 3s - retries: 20 - - rabbitmq: - image: rabbitmq:3-management - restart: always - ports: - - "5672:5672" - - "15672:15672" - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123} - volumes: - - rabbitmq_data:/var/lib/rabbitmq - healthcheck: - test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"] - interval: 5s - timeout: 5s - retries: 20 - - backend: - build: - context: . - dockerfile: backend/Dockerfile - target: api - restart: always + interval: 5s + timeout: 3s + retries: 20 + + rabbitmq: + image: rabbitmq:3-management + restart: always + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + target: api + restart: always environment: JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key} MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} @@ -66,30 +66,30 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD:-123456} RABBITMQ_USER: ${RABBITMQ_USER:-admin} RABBITMQ_PASS: ${RABBITMQ_PASS:-password123} - ports: - - "8080:8080" - volumes: - - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro - - backend_uploads:/app/.run/uploads - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "pgrep api || exit 1"] - interval: 10s - timeout: 5s - retries: 3 - - worker: - build: - context: . - dockerfile: backend/Dockerfile - target: worker - restart: always + ports: + - "8080:8080" + volumes: + - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro + - backend_uploads:/app/.run/uploads + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep api || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + + worker: + build: + context: . + dockerfile: backend/Dockerfile + target: worker + restart: always environment: JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key} MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} @@ -97,39 +97,39 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD:-123456} RABBITMQ_USER: ${RABBITMQ_USER:-admin} RABBITMQ_PASS: ${RABBITMQ_PASS:-password123} - volumes: - - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "pgrep worker || exit 1"] - interval: 15s - timeout: 5s - retries: 3 - - frontend: - build: - context: . - dockerfile: frontend/Dockerfile - restart: always - ports: - - "5173:80" - depends_on: - - backend - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"] - interval: 10s - timeout: 5s - retries: 3 - -volumes: - mysql_data: - redis_data: - rabbitmq_data: - backend_uploads: - + volumes: + - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep worker || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + restart: always + ports: + - "5173:80" + depends_on: + - backend + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + mysql_data: + redis_data: + rabbitmq_data: + backend_uploads: + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d26adbe..7c2aa3f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/api/account.ts b/frontend/src/api/account.ts index 8910197..ad6bd36 100644 --- a/frontend/src/api/account.ts +++ b/frontend/src/api/account.ts @@ -1,48 +1,48 @@ -import { postForm, postJson } from './client' -import type { Account, MessageResponse, TokenResponse } from './types' - -export function register(username: string, password: string) { - return postJson('/account/register', { username, password }) -} - -export function login(username: string, password: string) { - return postJson('/account/login', { username, password }) -} - -export function logout() { - return postJson('/account/logout', {}, { authRequired: true }) -} - -export function rename(newUsername: string) { - return postJson('/account/rename', { new_username: newUsername }, { authRequired: true }) -} - -export function changePassword(username: string, oldPassword: string, newPassword: string) { - return postJson('/account/changePassword', { - username, - old_password: oldPassword, - new_password: newPassword, - }) -} - -export function findById(id: number) { - return postJson('/account/findByID', { id }) -} - -export function findByUsername(username: string) { - return postJson('/account/findByUsername', { username }) -} - -export function uploadAvatar(file: File) { - const fd = new FormData() - fd.append('file', file) - return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true }) -} - -export function updateProfile(data: { avatar_url?: string; bio?: string }) { - return postJson('/account/updateProfile', data, { authRequired: true }) -} - -export function refresh(refreshToken: string) { - return postJson('/account/refresh', { refresh_token: refreshToken }) -} +import { postForm, postJson } from './client' +import type { Account, MessageResponse, TokenResponse } from './types' + +export function register(username: string, password: string) { + return postJson('/account/register', { username, password }) +} + +export function login(username: string, password: string) { + return postJson('/account/login', { username, password }) +} + +export function logout() { + return postJson('/account/logout', {}, { authRequired: true }) +} + +export function rename(newUsername: string) { + return postJson('/account/rename', { new_username: newUsername }, { authRequired: true }) +} + +export function changePassword(username: string, oldPassword: string, newPassword: string) { + return postJson('/account/changePassword', { + username, + old_password: oldPassword, + new_password: newPassword, + }) +} + +export function findById(id: number) { + return postJson('/account/findByID', { id }) +} + +export function findByUsername(username: string) { + return postJson('/account/findByUsername', { username }) +} + +export function uploadAvatar(file: File) { + const fd = new FormData() + fd.append('file', file) + return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true }) +} + +export function updateProfile(data: { avatar_url?: string; bio?: string }) { + return postJson('/account/updateProfile', data, { authRequired: true }) +} + +export function refresh(refreshToken: string) { + return postJson('/account/refresh', { refresh_token: refreshToken }) +} diff --git a/frontend/src/api/comment.ts b/frontend/src/api/comment.ts index 3003f63..798c3d5 100644 --- a/frontend/src/api/comment.ts +++ b/frontend/src/api/comment.ts @@ -1,16 +1,16 @@ -import { postJson } from './client' -import { normalizeCommentList } from './normalize' -import type { Comment, MessageResponse } from './types' - -export async function listAll(videoId: number) { - const comments = await postJson('/comment/listAll', { video_id: videoId }) - return normalizeCommentList(comments) -} - -export function publish(videoId: number, content: string) { - return postJson('/comment/publish', { video_id: videoId, content }, { authRequired: true }) -} - -export function remove(commentId: number) { - return postJson('/comment/delete', { comment_id: commentId }, { authRequired: true }) -} +import { postJson } from './client' +import { normalizeCommentList } from './normalize' +import type { Comment, MessageResponse } from './types' + +export async function listAll(videoId: number) { + const comments = await postJson('/comment/listAll', { video_id: videoId }) + return normalizeCommentList(comments) +} + +export function publish(videoId: number, content: string) { + return postJson('/comment/publish', { video_id: videoId, content }, { authRequired: true }) +} + +export function remove(commentId: number) { + return postJson('/comment/delete', { comment_id: commentId }, { authRequired: true }) +} diff --git a/frontend/src/api/feed.ts b/frontend/src/api/feed.ts index 5ace2b1..a7c8ad4 100644 --- a/frontend/src/api/feed.ts +++ b/frontend/src/api/feed.ts @@ -1,28 +1,28 @@ -import { postJson } from './client' -import { normalizeFeedVideoList } from './normalize' -import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types' - -export async function listLatest(input: { limit: number; latest_time: number }) { - const res = await postJson('/feed/listLatest', input) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) { - const body: Record = { limit: input.limit } - if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') { - body.likes_count_before = input.likes_count_before ?? 0 - body.id_before = input.id_before ?? 0 - } - const res = await postJson('/feed/listLikesCount', body) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) { - const res = await postJson('/feed/listByPopularity', input) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listByFollowing(input: { limit: number; latest_time: number }) { - const res = await postJson('/feed/listByFollowing', input, { authRequired: true }) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} +import { postJson } from './client' +import { normalizeFeedVideoList } from './normalize' +import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types' + +export async function listLatest(input: { limit: number; latest_time: number }) { + const res = await postJson('/feed/listLatest', input) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) { + const body: Record = { limit: input.limit } + if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') { + body.likes_count_before = input.likes_count_before ?? 0 + body.id_before = input.id_before ?? 0 + } + const res = await postJson('/feed/listLikesCount', body) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) { + const res = await postJson('/feed/listByPopularity', input) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listByFollowing(input: { limit: number; latest_time: number }) { + const res = await postJson('/feed/listByFollowing', input, { authRequired: true }) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} diff --git a/frontend/src/api/like.ts b/frontend/src/api/like.ts index 25bd29a..ce80cc0 100644 --- a/frontend/src/api/like.ts +++ b/frontend/src/api/like.ts @@ -1,18 +1,18 @@ -import { postJson } from './client' -import type { IsLikedResponse, MessageResponse, Video } from './types' - -export function like(videoId: number) { - return postJson('/like/like', { video_id: videoId }, { authRequired: true }) -} - -export function unlike(videoId: number) { - return postJson('/like/unlike', { video_id: videoId }, { authRequired: true }) -} - -export function isLiked(videoId: number) { - return postJson('/like/isLiked', { video_id: videoId }, { authRequired: true }) -} - -export function listMyLikedVideos() { - return postJson('/like/listMyLikedVideos', {}, { authRequired: true }) -} +import { postJson } from './client' +import type { IsLikedResponse, MessageResponse, Video } from './types' + +export function like(videoId: number) { + return postJson('/like/like', { video_id: videoId }, { authRequired: true }) +} + +export function unlike(videoId: number) { + return postJson('/like/unlike', { video_id: videoId }, { authRequired: true }) +} + +export function isLiked(videoId: number) { + return postJson('/like/isLiked', { video_id: videoId }, { authRequired: true }) +} + +export function listMyLikedVideos() { + return postJson('/like/listMyLikedVideos', {}, { authRequired: true }) +} diff --git a/frontend/src/api/normalize.ts b/frontend/src/api/normalize.ts index 1e58444..1b06a34 100644 --- a/frontend/src/api/normalize.ts +++ b/frontend/src/api/normalize.ts @@ -1,57 +1,57 @@ -import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types' - -export function listOrEmpty(value: T[] | null | undefined): T[] { - return Array.isArray(value) ? value : [] -} - -export function normalizeAccount(value: Account | null | undefined): Account { - return { - id: Number(value?.id ?? 0), - username: value?.username || '匿名用户', - } -} - -function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor { - return { - id: Number(value?.id ?? 0), - username: value?.username || '匿名用户', - } -} - -export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem { - return { - ...value, - author: normalizeAuthor(value.author), - title: value.title || '未命名视频', - description: value.description || '', - play_url: value.play_url || '', - cover_url: value.cover_url || '', - create_time: Number(value.create_time ?? 0), - likes_count: Number(value.likes_count ?? 0), - is_liked: Boolean(value.is_liked), - } -} - -export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] { - return listOrEmpty(value).map(normalizeFeedVideoItem) -} - -export function normalizeVideoList(value: Video[] | null | undefined): Video[] { - return listOrEmpty(value).map((video) => ({ - ...video, - username: video.username || '匿名用户', - title: video.title || '未命名视频', - description: video.description || '', - play_url: video.play_url || '', - cover_url: video.cover_url || '', - likes_count: Number(video.likes_count ?? 0), - })) -} - -export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] { - return listOrEmpty(value).map((comment) => ({ - ...comment, - username: comment.username || '匿名用户', - content: comment.content || '', - })) -} +import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types' + +export function listOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +export function normalizeAccount(value: Account | null | undefined): Account { + return { + id: Number(value?.id ?? 0), + username: value?.username || '匿名用户', + } +} + +function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor { + return { + id: Number(value?.id ?? 0), + username: value?.username || '匿名用户', + } +} + +export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem { + return { + ...value, + author: normalizeAuthor(value.author), + title: value.title || '未命名视频', + description: value.description || '', + play_url: value.play_url || '', + cover_url: value.cover_url || '', + create_time: Number(value.create_time ?? 0), + likes_count: Number(value.likes_count ?? 0), + is_liked: Boolean(value.is_liked), + } +} + +export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] { + return listOrEmpty(value).map(normalizeFeedVideoItem) +} + +export function normalizeVideoList(value: Video[] | null | undefined): Video[] { + return listOrEmpty(value).map((video) => ({ + ...video, + username: video.username || '匿名用户', + title: video.title || '未命名视频', + description: video.description || '', + play_url: video.play_url || '', + cover_url: video.cover_url || '', + likes_count: Number(video.likes_count ?? 0), + })) +} + +export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] { + return listOrEmpty(value).map((comment) => ({ + ...comment, + username: comment.username || '匿名用户', + content: comment.content || '', + })) +} diff --git a/frontend/src/api/social.ts b/frontend/src/api/social.ts index 514e7c2..6af442e 100644 --- a/frontend/src/api/social.ts +++ b/frontend/src/api/social.ts @@ -1,29 +1,29 @@ -import { postJson } from './client' -import { listOrEmpty, normalizeAccount } from './normalize' -import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types' - -export function follow(vloggerId: number) { - return postJson('/social/follow', { vlogger_id: vloggerId }, { authRequired: true }) -} - -export function unfollow(vloggerId: number) { - return postJson('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true }) -} - -export async function getAllFollowers(vloggerId?: number) { - const res = await postJson( - '/social/getAllFollowers', - vloggerId ? { vlogger_id: vloggerId } : {}, - { authRequired: true }, - ) - return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) } -} - -export async function getAllVloggers(followerId?: number) { - const res = await postJson( - '/social/getAllVloggers', - followerId ? { follower_id: followerId } : {}, - { authRequired: true }, - ) - return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) } -} +import { postJson } from './client' +import { listOrEmpty, normalizeAccount } from './normalize' +import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types' + +export function follow(vloggerId: number) { + return postJson('/social/follow', { vlogger_id: vloggerId }, { authRequired: true }) +} + +export function unfollow(vloggerId: number) { + return postJson('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true }) +} + +export async function getAllFollowers(vloggerId?: number) { + const res = await postJson( + '/social/getAllFollowers', + vloggerId ? { vlogger_id: vloggerId } : {}, + { authRequired: true }, + ) + return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) } +} + +export async function getAllVloggers(followerId?: number) { + const res = await postJson( + '/social/getAllVloggers', + followerId ? { follower_id: followerId } : {}, + { authRequired: true }, + ) + return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 37d30a5..7c1f6b1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -14,89 +14,89 @@ export type ListMessagesResponse = { } export type TokenResponse = { token: string; refresh_token?: string; account_id?: number; username?: string } - -export type Account = { - id: number - username: string - avatar_url?: string - bio?: string -} - -export type Video = { - id: number - author_id: number - username: string - title: string - description?: string - play_url: string - cover_url: string - create_time: string - likes_count: number -} - -export type Comment = { - id: number - username: string - video_id: number - author_id: number - content: string - created_at: string -} - -export type FeedAuthor = { - id: number - username: string -} - -export type FeedVideoItem = { - id: number - author: FeedAuthor - title: string - description?: string - play_url: string - cover_url: string - create_time: number - likes_count: number - is_liked: boolean -} - -export type ListLatestResponse = { - video_list: FeedVideoItem[] - next_time: number - has_more: boolean -} - -export type ListLikesCountResponse = { - video_list: FeedVideoItem[] - next_likes_count_before?: number - next_id_before?: number - has_more: boolean -} - -export type ListByPopularityResponse = { - video_list: FeedVideoItem[] - as_of: number - next_offset: number - has_more: boolean - next_latest_popularity?: number - next_latest_before?: string - next_latest_id_before?: number -} - -export type ListByFollowingResponse = { - video_list: FeedVideoItem[] - next_time: number - has_more: boolean -} - -export type IsLikedResponse = { - is_liked: boolean -} - -export type GetAllFollowersResponse = { - followers: Account[] -} - -export type GetAllVloggersResponse = { - vloggers: Account[] -} + +export type Account = { + id: number + username: string + avatar_url?: string + bio?: string +} + +export type Video = { + id: number + author_id: number + username: string + title: string + description?: string + play_url: string + cover_url: string + create_time: string + likes_count: number +} + +export type Comment = { + id: number + username: string + video_id: number + author_id: number + content: string + created_at: string +} + +export type FeedAuthor = { + id: number + username: string +} + +export type FeedVideoItem = { + id: number + author: FeedAuthor + title: string + description?: string + play_url: string + cover_url: string + create_time: number + likes_count: number + is_liked: boolean +} + +export type ListLatestResponse = { + video_list: FeedVideoItem[] + next_time: number + has_more: boolean +} + +export type ListLikesCountResponse = { + video_list: FeedVideoItem[] + next_likes_count_before?: number + next_id_before?: number + has_more: boolean +} + +export type ListByPopularityResponse = { + video_list: FeedVideoItem[] + as_of: number + next_offset: number + has_more: boolean + next_latest_popularity?: number + next_latest_before?: string + next_latest_id_before?: number +} + +export type ListByFollowingResponse = { + video_list: FeedVideoItem[] + next_time: number + has_more: boolean +} + +export type IsLikedResponse = { + is_liked: boolean +} + +export type GetAllFollowersResponse = { + followers: Account[] +} + +export type GetAllVloggersResponse = { + vloggers: Account[] +} diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 97756da..677389b 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,93 +1,93 @@ - - -