From 025be6dd78c7d9b96c3a95d44e87604cf180051e Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 16:03:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(P2):=20Handler=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E7=B2=BE=E7=A1=AE=E5=8C=96=20=E2=80=94=20service=20?= =?UTF-8?q?=E5=B1=82=E5=BC=95=E5=85=A5=E5=93=A8=E5=85=B5=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=8Chandler=20=E5=B1=82=E7=94=A8=20ClassifyHTTPStatus=20?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/account/handler.go | 286 +++++++------- backend/internal/feed/handler.go | 351 ++++++++--------- backend/internal/http/errors.go | 28 ++ backend/internal/social/handler.go | 251 ++++++------ backend/internal/video/comment_handler.go | 195 +++++----- backend/internal/video/comment_service.go | 233 ++++++------ backend/internal/video/like_handler.go | 227 +++++------ backend/internal/video/video_handler.go | 27 +- backend/internal/video/video_service.go | 441 +++++++++++----------- 9 files changed, 1038 insertions(+), 1001 deletions(-) create mode 100644 backend/internal/http/errors.go diff --git a/backend/internal/account/handler.go b/backend/internal/account/handler.go index 84df5b3..a72b87e 100644 --- a/backend/internal/account/handler.go +++ b/backend/internal/account/handler.go @@ -1,142 +1,144 @@ -package account - -import ( - "errors" - - "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(400, 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(400, gin.H{"error": err.Error()}) - return - } - accountID, err := getAccountID(c) - if err != nil { - c.JSON(400, 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(400, 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(400, 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(400, 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(400, 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(400, gin.H{"error": err.Error()}) - return - } - if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, gin.H{"token": token}) - } -} - -func (h *AccountHandler) Logout(c *gin.Context) { - accountID, err := getAccountID(c) - if err != nil { - c.JSON(400, 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 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 ( + "errors" + + httputil "feedsystem_video_go/internal/http" + + "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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := getAccountID(c) + if err != nil { + c.JSON(httputil.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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, gin.H{"token": token}) + } +} + +func (h *AccountHandler) Logout(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(httputil.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 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/feed/handler.go b/backend/internal/feed/handler.go index 48a427d..431bc2c 100644 --- a/backend/internal/feed/handler.go +++ b/backend/internal/feed/handler.go @@ -1,175 +1,176 @@ -package feed - -import ( - "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(400, 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(400, 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(400, 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(400, 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 -} +package feed + +import ( + "feedsystem_video_go/internal/middleware/jwt" + httputil "feedsystem_video_go/internal/http" + "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(httputil.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(httputil.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(httputil.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(httputil.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 +} diff --git a/backend/internal/http/errors.go b/backend/internal/http/errors.go new file mode 100644 index 0000000..a0e33e2 --- /dev/null +++ b/backend/internal/http/errors.go @@ -0,0 +1,28 @@ +package http + +import ( + "errors" + "net/http" + + "gorm.io/gorm" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrValidation = errors.New("validation error") +) + +func ClassifyHTTPStatus(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, ErrUnauthorized): + return http.StatusUnauthorized + case errors.Is(err, ErrValidation): + return http.StatusBadRequest + case errors.Is(err, gorm.ErrRecordNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/backend/internal/social/handler.go b/backend/internal/social/handler.go index 22e20bf..7d95bb6 100644 --- a/backend/internal/social/handler.go +++ b/backend/internal/social/handler.go @@ -1,125 +1,126 @@ -package social - -import ( - "feedsystem_video_go/internal/account" - "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(http.StatusBadRequest, 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(http.StatusInternalServerError, 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(http.StatusBadRequest, 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(http.StatusInternalServerError, 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(http.StatusBadRequest, 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(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if followers == nil { - followers = []*account.Account{} - } - c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers}) -} - -func (h *SocialHandler) GetAllVloggers(c *gin.Context) { - var req GetAllVloggersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, 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(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if vloggers == nil { - vloggers = []*account.Account{} - } - c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers}) -} +package social + +import ( + "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" + "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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if followers == nil { + followers = []*account.Account{} + } + c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers}) +} + +func (h *SocialHandler) GetAllVloggers(c *gin.Context) { + var req GetAllVloggersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if vloggers == nil { + vloggers = []*account.Account{} + } + c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers}) +} diff --git a/backend/internal/video/comment_handler.go b/backend/internal/video/comment_handler.go index 0ed9a7b..d370b37 100644 --- a/backend/internal/video/comment_handler.go +++ b/backend/internal/video/comment_handler.go @@ -1,97 +1,98 @@ -package video - -import ( - "feedsystem_video_go/internal/account" - "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(400, 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(400, gin.H{"error": err.Error()}) - return - } - user, err := h.accountService.FindByID(c.Request.Context(), authorId) - if err != nil { - c.JSON(400, 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(400, 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(400, gin.H{"error": err.Error()}) - return - } - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, 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(400, 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(400, 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(400, gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []Comment{} - } - c.JSON(200, comments) -} +package video + +import ( + "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" + "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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + user, err := h.accountService.FindByID(c.Request.Context(), authorId) + if err != nil { + c.JSON(httputil.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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.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(httputil.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(httputil.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(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if comments == nil { + comments = []Comment{} + } + c.JSON(200, comments) +} diff --git a/backend/internal/video/comment_service.go b/backend/internal/video/comment_service.go index 875c2b0..2f502ba 100644 --- a/backend/internal/video/comment_service.go +++ b/backend/internal/video/comment_service.go @@ -1,116 +1,117 @@ -package video - -import ( - "context" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "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 { - 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) - } - 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 errors.New("permission denied") - } - 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) -} +package video + +import ( + "context" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + httputil "feedsystem_video_go/internal/http" + "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 { + 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) + } + 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 httputil.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) +} diff --git a/backend/internal/video/like_handler.go b/backend/internal/video/like_handler.go index 66d595f..39aa2a9 100644 --- a/backend/internal/video/like_handler.go +++ b/backend/internal/video/like_handler.go @@ -1,113 +1,114 @@ -package video - -import ( - "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(400, 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(400, 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(400, 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(400, 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(400, 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(400, 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(400, 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/middleware/jwt" + httputil "feedsystem_video_go/internal/http" + + "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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.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(httputil.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/video_handler.go b/backend/internal/video/video_handler.go index 8319698..988878d 100644 --- a/backend/internal/video/video_handler.go +++ b/backend/internal/video/video_handler.go @@ -12,6 +12,7 @@ import ( "time" "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" "feedsystem_video_go/internal/middleware/jwt" "github.com/gin-gonic/gin" @@ -29,18 +30,18 @@ func NewVideoHandler(service *VideoService, accountService *account.AccountServi func (vh *VideoHandler) PublishVideo(c *gin.Context) { var req PublishVideoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } authorId, err := jwt.GetAccountID(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } username, err := jwt.GetUsername(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } video := &Video{ @@ -53,7 +54,7 @@ func (vh *VideoHandler) PublishVideo(c *gin.Context) { CreateTime: time.Now(), } if err := vh.service.Publish(c.Request.Context(), video); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, video) @@ -193,16 +194,16 @@ func buildAbsoluteURL(c *gin.Context, p string) string { func (vh *VideoHandler) DeleteVideo(c *gin.Context) { var req DeleteVideoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } authorId, err := jwt.GetAccountID(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "video deleted"}) @@ -211,12 +212,12 @@ func (vh *VideoHandler) DeleteVideo(c *gin.Context) { func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { var req ListByAuthorIDRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if videos == nil { @@ -228,12 +229,12 @@ func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { func (vh *VideoHandler) GetDetail(c *gin.Context) { var req GetDetailRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } video, err := vh.service.GetDetail(c.Request.Context(), req.ID) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, video) @@ -242,11 +243,11 @@ func (vh *VideoHandler) GetDetail(c *gin.Context) { func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { var req UpdateLikesCountRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "likes count updated"}) diff --git a/backend/internal/video/video_service.go b/backend/internal/video/video_service.go index 92eba9a..d952066 100644 --- a/backend/internal/video/video_service.go +++ b/backend/internal/video/video_service.go @@ -1,220 +1,221 @@ -package video - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "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 - } - 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 errors.New("unauthorized") - } - if err := vs.repo.DeleteVideo(ctx, id); err != nil { - return err - } - if vs.cache != nil { - cacheKey := fmt.Sprintf("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 := fmt.Sprintf("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(), fmt.Sprintf("video:detail:id=%d", id)) - - // 2) 热榜:写到“时间窗ZSET”,不要用 detail key - now := time.Now().UTC().Truncate(time.Minute) - windowKey := "hot:video:1m:" + 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" + "fmt" + "strconv" + "strings" + "time" + + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + httputil "feedsystem_video_go/internal/http" + + "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 + } + 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 httputil.ErrUnauthorized + } + if err := vs.repo.DeleteVideo(ctx, id); err != nil { + return err + } + if vs.cache != nil { + cacheKey := fmt.Sprintf("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 := fmt.Sprintf("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(), fmt.Sprintf("video:detail:id=%d", id)) + + // 2) 热榜:写到“时间窗ZSET”,不要用 detail key + now := time.Now().UTC().Truncate(time.Minute) + windowKey := "hot:video:1m:" + 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 +}