refactor(P2): Handler 错误码精确化 — service 层引入哨兵错误,handler 层用 ClassifyHTTPStatus 分类

This commit is contained in:
Sisyphus
2026-04-25 16:03:29 +08:00
parent 7d02262098
commit 025be6dd78
9 changed files with 1038 additions and 1001 deletions

View File

@@ -1,142 +1,144 @@
package account package account
import ( import (
"errors" "errors"
"github.com/gin-gonic/gin" httputil "feedsystem_video_go/internal/http"
"gorm.io/gorm"
) "github.com/gin-gonic/gin"
"gorm.io/gorm"
type AccountHandler struct { )
accountService *AccountService
} type AccountHandler struct {
accountService *AccountService
func NewAccountHandler(accountService *AccountService) *AccountHandler { }
return &AccountHandler{accountService: accountService}
} func NewAccountHandler(accountService *AccountService) *AccountHandler {
func (h *AccountHandler) CreateAccount(c *gin.Context) { return &AccountHandler{accountService: accountService}
var req CreateAccountRequest }
if err := c.ShouldBindJSON(&req); err != nil { func (h *AccountHandler) CreateAccount(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req CreateAccountRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{ return
Username: req.Username, }
Password: req.Password, if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
}); err != nil { Username: req.Username,
c.JSON(500, gin.H{"error": err.Error()}) Password: req.Password,
return }); err != nil {
} c.JSON(500, gin.H{"error": err.Error()})
c.JSON(200, gin.H{"message": "account created"}) 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 { func (h *AccountHandler) Rename(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req RenameRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
accountID, err := getAccountID(c) return
if err != nil { }
c.JSON(400, gin.H{"error": err.Error()}) accountID, err := getAccountID(c)
return if err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername) return
if err != nil { }
if errors.Is(err, ErrNewUsernameRequired) { token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return if errors.Is(err, ErrNewUsernameRequired) {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if errors.Is(err, ErrUsernameTaken) { return
c.JSON(409, gin.H{"error": err.Error()}) }
return if errors.Is(err, ErrUsernameTaken) {
} c.JSON(409, gin.H{"error": err.Error()})
if errors.Is(err, gorm.ErrRecordNotFound) { return
c.JSON(404, gin.H{"error": "account not found"}) }
return if errors.Is(err, gorm.ErrRecordNotFound) {
} c.JSON(404, gin.H{"error": "account not found"})
c.JSON(500, gin.H{"error": err.Error()}) return
return }
} c.JSON(500, gin.H{"error": err.Error()})
c.JSON(200, gin.H{"token": token}) 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 { func (h *AccountHandler) ChangePassword(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req ChangePasswordRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil { return
c.JSON(400, gin.H{"error": "unsuccessfully password changed"}) }
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"})
c.JSON(200, gin.H{"message": "successfully 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 { func (h *AccountHandler) FindByID(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req FindByIDRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil { return
c.JSON(500, gin.H{"error": err.Error()}) }
return if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
} else { c.JSON(500, gin.H{"error": err.Error()})
c.JSON(200, account) return
} } else {
} c.JSON(200, account)
}
func (h *AccountHandler) FindByUsername(c *gin.Context) { }
var req FindByUsernameRequest
if err := c.ShouldBindJSON(&req); err != nil { func (h *AccountHandler) FindByUsername(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req FindByUsernameRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil { return
c.JSON(500, gin.H{"error": err.Error()}) }
return if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
} else { c.JSON(500, gin.H{"error": err.Error()})
c.JSON(200, account) return
} } else {
} c.JSON(200, account)
}
func (h *AccountHandler) Login(c *gin.Context) { }
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { func (h *AccountHandler) Login(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) var req LoginRequest
return if err := c.ShouldBindJSON(&req); err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { return
c.JSON(500, gin.H{"error": err.Error()}) }
return if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil {
} else { c.JSON(500, gin.H{"error": err.Error()})
c.JSON(200, gin.H{"token": token}) return
} } else {
} c.JSON(200, gin.H{"token": token})
}
func (h *AccountHandler) Logout(c *gin.Context) { }
accountID, err := getAccountID(c)
if err != nil { func (h *AccountHandler) Logout(c *gin.Context) {
c.JSON(400, gin.H{"error": err.Error()}) accountID, err := getAccountID(c)
return if err != nil {
} c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil { return
c.JSON(500, 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()})
c.JSON(200, gin.H{"message": "account logged out"}) return
} }
c.JSON(200, gin.H{"message": "account logged out"})
func getAccountID(c *gin.Context) (uint, error) { }
value, exists := c.Get("accountID")
if !exists { func getAccountID(c *gin.Context) (uint, error) {
return 0, errors.New("accountID not found") value, exists := c.Get("accountID")
} if !exists {
id, ok := value.(uint) return 0, errors.New("accountID not found")
if !ok { }
return 0, errors.New("accountID has invalid type") id, ok := value.(uint)
} if !ok {
return id, nil return 0, errors.New("accountID has invalid type")
} }
return id, nil
}

View File

@@ -1,175 +1,176 @@
package feed package feed
import ( import (
"feedsystem_video_go/internal/middleware/jwt" "feedsystem_video_go/internal/middleware/jwt"
"time" httputil "feedsystem_video_go/internal/http"
"time"
"github.com/gin-gonic/gin"
) "github.com/gin-gonic/gin"
)
type FeedHandler struct {
service *FeedService type FeedHandler struct {
} service *FeedService
}
func NewFeedHandler(service *FeedService) *FeedHandler {
return &FeedHandler{service: service} func NewFeedHandler(service *FeedService) *FeedHandler {
} return &FeedHandler{service: service}
}
func (f *FeedHandler) ListLatest(c *gin.Context) {
var req ListLatestRequest func (f *FeedHandler) ListLatest(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req ListLatestRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if req.Limit <= 0 || req.Limit > 50 { }
req.Limit = 10 if req.Limit <= 0 || req.Limit > 50 {
} req.Limit = 10
var latestTime time.Time }
if req.LatestTime > 0 { var latestTime time.Time
latestTime = time.UnixMilli(req.LatestTime) if req.LatestTime > 0 {
} latestTime = time.UnixMilli(req.LatestTime)
viewerAccountID, err := jwt.GetAccountID(c) }
if err != nil { viewerAccountID, err := jwt.GetAccountID(c)
viewerAccountID = 0 if err != nil {
} viewerAccountID = 0
feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID) }
if err != nil { feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) }
c.JSON(200, feedItems) feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
} c.JSON(200, feedItems)
}
func (f *FeedHandler) ListLikesCount(c *gin.Context) {
var req ListLikesCountRequest func (f *FeedHandler) ListLikesCount(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req ListLikesCountRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if req.Limit <= 0 || req.Limit > 50 { }
req.Limit = 10 if req.Limit <= 0 || req.Limit > 50 {
} req.Limit = 10
}
var cursor *LikesCountCursor
if req.LikesCountBefore != nil || req.IDBefore != nil { 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"}) if req.LikesCountBefore == nil || req.IDBefore == nil {
return c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"})
} return
}
likesCountBefore := *req.LikesCountBefore
idBefore := *req.IDBefore likesCountBefore := *req.LikesCountBefore
idBefore := *req.IDBefore
if likesCountBefore < 0 {
c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"}) if likesCountBefore < 0 {
return c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"})
} return
if idBefore == 0 { }
if likesCountBefore != 0 { if idBefore == 0 {
c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"}) if likesCountBefore != 0 {
return c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"})
} return
} else { }
cursor = &LikesCountCursor{ } else {
LikesCount: likesCountBefore, cursor = &LikesCountCursor{
ID: idBefore, LikesCount: likesCountBefore,
} ID: idBefore,
} }
} }
viewerAccountID, err := jwt.GetAccountID(c) }
if err != nil { viewerAccountID, err := jwt.GetAccountID(c)
viewerAccountID = 0 if err != nil {
} viewerAccountID = 0
feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID) }
if err != nil { feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID)
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) }
c.JSON(200, feedItems) feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
} c.JSON(200, feedItems)
}
func (f *FeedHandler) ListByFollowing(c *gin.Context) {
var req ListByFollowingRequest func (f *FeedHandler) ListByFollowing(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req ListByFollowingRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if req.Limit <= 0 || req.Limit > 50 { }
req.Limit = 10 if req.Limit <= 0 || req.Limit > 50 {
} req.Limit = 10
viewerAccountID, err := jwt.GetAccountID(c) }
if err != nil { viewerAccountID, err := jwt.GetAccountID(c)
viewerAccountID = 0 if err != nil {
} viewerAccountID = 0
var latestTime time.Time }
if req.LatestTime > 0 { var latestTime time.Time
latestTime = time.Unix(req.LatestTime, 0) 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 { feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) }
c.JSON(200, feedItems) feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
} c.JSON(200, feedItems)
}
func (f *FeedHandler) ListByPopularity(c *gin.Context) {
var req ListByPopularityRequest func (f *FeedHandler) ListByPopularity(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req ListByPopularityRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if req.Limit <= 0 || req.Limit > 50 { }
req.Limit = 10 if req.Limit <= 0 || req.Limit > 50 {
} req.Limit = 10
viewerAccountID, err := jwt.GetAccountID(c) }
if err != nil { viewerAccountID, err := jwt.GetAccountID(c)
viewerAccountID = 0 if err != nil {
} viewerAccountID = 0
}
var latestPopularity int64
var latestBefore time.Time var latestPopularity int64
var latestIDBefore uint var latestBefore time.Time
var latestIDBefore uint
if req.LatestPopularity < 0 {
c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) if req.LatestPopularity < 0 {
return c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"})
} return
}
anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil
if anyCursor { anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil
if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { if anyCursor {
c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 {
return c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"})
} return
latestPopularity = req.LatestPopularity }
latestBefore = req.LatestBefore latestPopularity = req.LatestPopularity
latestIDBefore = *req.LatestIDBefore latestBefore = req.LatestBefore
} latestIDBefore = *req.LatestIDBefore
resp, err := f.service.ListByPopularity( }
c.Request.Context(), resp, err := f.service.ListByPopularity(
req.Limit, c.Request.Context(),
req.AsOf, req.Limit,
req.Offset, req.AsOf,
viewerAccountID, req.Offset,
latestPopularity, viewerAccountID,
latestBefore, latestPopularity,
latestIDBefore, latestBefore,
) latestIDBefore,
if err != nil { )
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
resp.VideoList = nonNilFeedVideoItems(resp.VideoList) }
c.JSON(200, resp) resp.VideoList = nonNilFeedVideoItems(resp.VideoList)
} c.JSON(200, resp)
}
func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
if items == nil { func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
return []FeedVideoItem{} if items == nil {
} return []FeedVideoItem{}
return items }
} return items
}

View File

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

View File

@@ -1,125 +1,126 @@
package social package social
import ( import (
"feedsystem_video_go/internal/account" "feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware/jwt" httputil "feedsystem_video_go/internal/http"
"net/http" "feedsystem_video_go/internal/middleware/jwt"
"net/http"
"github.com/gin-gonic/gin"
) "github.com/gin-gonic/gin"
)
type SocialHandler struct {
service *SocialService type SocialHandler struct {
} service *SocialService
}
func NewSocialHandler(service *SocialService) *SocialHandler {
return &SocialHandler{service: service} func NewSocialHandler(service *SocialService) *SocialHandler {
} return &SocialHandler{service: service}
}
func (h *SocialHandler) Follow(c *gin.Context) {
var req FollowRequest func (h *SocialHandler) Follow(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req FollowRequest
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VloggerID <= 0 {
return c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
} return
FollowerID, err := jwt.GetAccountID(c) }
if err != nil { FollowerID, err := jwt.GetAccountID(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
} return
social := &Social{ }
FollowerID: FollowerID, social := &Social{
VloggerID: req.VloggerID, 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()}) if err := h.service.Follow(c.Request.Context(), social); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
c.JSON(http.StatusOK, gin.H{"message": "followed"}) }
} c.JSON(http.StatusOK, gin.H{"message": "followed"})
}
func (h *SocialHandler) Unfollow(c *gin.Context) {
var req UnfollowRequest func (h *SocialHandler) Unfollow(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req UnfollowRequest
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VloggerID <= 0 {
return c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
} return
FollowerID, err := jwt.GetAccountID(c) }
if err != nil { FollowerID, err := jwt.GetAccountID(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
} return
social := &Social{ }
FollowerID: FollowerID, social := &Social{
VloggerID: req.VloggerID, 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()}) if err := h.service.Unfollow(c.Request.Context(), social); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"}) }
} c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
}
func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
var req GetAllFollowersRequest func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req GetAllFollowersRequest
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
vloggerID := req.VloggerID
if vloggerID == 0 { vloggerID := req.VloggerID
accountID, err := jwt.GetAccountID(c) if vloggerID == 0 {
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
} return
vloggerID = accountID }
} vloggerID = accountID
}
followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID)
if err != nil { followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if followers == nil { }
followers = []*account.Account{} if followers == nil {
} followers = []*account.Account{}
c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers}) }
} c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers})
}
func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
var req GetAllVloggersRequest func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req GetAllVloggersRequest
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
followerID := req.FollowerID
if followerID == 0 { followerID := req.FollowerID
accountID, err := jwt.GetAccountID(c) if followerID == 0 {
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
} return
followerID = accountID }
} followerID = accountID
}
vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID)
if err != nil { vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if vloggers == nil { }
vloggers = []*account.Account{} if vloggers == nil {
} vloggers = []*account.Account{}
c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers}) }
} c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers})
}

View File

@@ -1,97 +1,98 @@
package video package video
import ( import (
"feedsystem_video_go/internal/account" "feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware/jwt" httputil "feedsystem_video_go/internal/http"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
) "github.com/gin-gonic/gin"
)
type CommentHandler struct {
service *CommentService type CommentHandler struct {
accountService *account.AccountService service *CommentService
} accountService *account.AccountService
}
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
return &CommentHandler{service: service, accountService: 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 func (h *CommentHandler) PublishComment(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req PublishCommentRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if req.Content == "" { }
c.JSON(400, gin.H{"error": "content is required"}) if req.Content == "" {
return c.JSON(400, gin.H{"error": "content is required"})
} return
if req.VideoID <= 0 { }
c.JSON(400, gin.H{"error": "video_id is required"}) if req.VideoID <= 0 {
return c.JSON(400, gin.H{"error": "video_id is required"})
} return
authorId, err := jwt.GetAccountID(c) }
if err != nil { authorId, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
user, err := h.accountService.FindByID(c.Request.Context(), authorId) }
if err != nil { user, err := h.accountService.FindByID(c.Request.Context(), authorId)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
comment := &Comment{ }
Username: user.Username, comment := &Comment{
VideoID: req.VideoID, Username: user.Username,
AuthorID: authorId, VideoID: req.VideoID,
Content: req.Content, AuthorID: authorId,
} Content: req.Content,
if err := h.service.Publish(c.Request.Context(), comment); err != nil { }
c.JSON(400, gin.H{"error": err.Error()}) if err := h.service.Publish(c.Request.Context(), comment); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
c.JSON(200, gin.H{"message": "comment published successfully"}) }
} c.JSON(200, gin.H{"message": "comment published successfully"})
}
func (h *CommentHandler) DeleteComment(c *gin.Context) {
var req DeleteCommentRequest func (h *CommentHandler) DeleteComment(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req DeleteCommentRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
accountID, err := jwt.GetAccountID(c) }
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return 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"}) if req.CommentID <= 0 {
return 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()}) if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
c.JSON(200, gin.H{"message": "comment deleted successfully"})
} c.JSON(200, gin.H{"message": "comment deleted successfully"})
}
func (h *CommentHandler) GetAllComments(c *gin.Context) {
var req GetAllCommentsRequest func (h *CommentHandler) GetAllComments(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req GetAllCommentsRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VideoID == 0 {
return c.JSON(400, gin.H{"error": "video_id is required"})
} return
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID) }
if err != nil { comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
if comments == nil { }
comments = []Comment{} if comments == nil {
} comments = []Comment{}
c.JSON(200, comments) }
} c.JSON(200, comments)
}

View File

@@ -1,116 +1,117 @@
package video package video
import ( import (
"context" "context"
"errors" "errors"
"feedsystem_video_go/internal/middleware/rabbitmq" "feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis" rediscache "feedsystem_video_go/internal/middleware/redis"
"strings" httputil "feedsystem_video_go/internal/http"
"strings"
"gorm.io/gorm"
) "gorm.io/gorm"
)
type CommentService struct {
repo *CommentRepository type CommentService struct {
VideoRepository *VideoRepository repo *CommentRepository
cache *rediscache.Client VideoRepository *VideoRepository
commentMQ *rabbitmq.CommentMQ cache *rediscache.Client
popularityMQ *rabbitmq.PopularityMQ 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 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 { func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
return errors.New("comment is nil") if comment == nil {
} return errors.New("comment is nil")
comment.Username = strings.TrimSpace(comment.Username) }
comment.Content = strings.TrimSpace(comment.Content) comment.Username = strings.TrimSpace(comment.Username)
if comment.VideoID == 0 || comment.AuthorID == 0 { comment.Content = strings.TrimSpace(comment.Content)
return errors.New("video_id and author_id are required") 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") if comment.Content == "" {
} return errors.New("content is required")
}
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
if err != nil { exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
return err if err != nil {
} return err
if !exists { }
return errors.New("video not found") if !exists {
} return errors.New("video not found")
}
mysqlEnqueued := false
redisEnqueued := false mysqlEnqueued := false
if s.commentMQ != nil { redisEnqueued := false
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil { if s.commentMQ != nil {
mysqlEnqueued = true 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 { if s.popularityMQ != nil {
redisEnqueued = true if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
} redisEnqueued = true
} }
if mysqlEnqueued && redisEnqueued { }
return nil if mysqlEnqueued && redisEnqueued {
} return nil
}
// Fallback: direct MySQL write when comment MQ publish fails.
if !mysqlEnqueued { // Fallback: direct MySQL write when comment MQ publish fails.
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if !mysqlEnqueued {
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil { if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if errors.Is(err, gorm.ErrRecordNotFound) { if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
return errors.New("video not found") if errors.Is(err, gorm.ErrRecordNotFound) {
} return errors.New("video not found")
return err }
} return err
if err := tx.Create(comment).Error; err != nil { }
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 return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
}); err != nil { UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
return err }); err != nil {
} return err
} }
}
// Fallback: direct Redis update when popularity MQ publish fails.
if !redisEnqueued { // Fallback: direct Redis update when popularity MQ publish fails.
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) if !redisEnqueued {
} UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
return nil }
} return nil
}
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
comment, err := s.repo.GetByID(ctx, commentID) func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
if err != nil { comment, err := s.repo.GetByID(ctx, commentID)
return err if err != nil {
} return err
if comment == nil { }
return errors.New("comment not found") if comment == nil {
} return errors.New("comment not found")
if comment.AuthorID != accountID { }
return errors.New("permission denied") if comment.AuthorID != accountID {
} return httputil.ErrUnauthorized
if s.commentMQ != nil { }
if err := s.commentMQ.Delete(ctx, commentID); err == nil { if s.commentMQ != nil {
return nil if err := s.commentMQ.Delete(ctx, commentID); err == nil {
} return nil
} }
return s.repo.DeleteComment(ctx, comment) }
} return s.repo.DeleteComment(ctx, comment)
}
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
exists, err := s.VideoRepository.IsExist(ctx, videoID) func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
if err != nil { exists, err := s.VideoRepository.IsExist(ctx, videoID)
return nil, err if err != nil {
} return nil, err
if !exists { }
return nil, errors.New("video not found") if !exists {
} return nil, errors.New("video not found")
return s.repo.GetAllComments(ctx, videoID) }
} return s.repo.GetAllComments(ctx, videoID)
}

View File

@@ -1,113 +1,114 @@
package video package video
import ( import (
"feedsystem_video_go/internal/middleware/jwt" "feedsystem_video_go/internal/middleware/jwt"
httputil "feedsystem_video_go/internal/http"
"github.com/gin-gonic/gin"
) "github.com/gin-gonic/gin"
)
type LikeHandler struct {
service *LikeService type LikeHandler struct {
} service *LikeService
}
func NewLikeHandler(service *LikeService) *LikeHandler {
return &LikeHandler{service: service} func NewLikeHandler(service *LikeService) *LikeHandler {
} return &LikeHandler{service: service}
}
func (lh *LikeHandler) Like(c *gin.Context) {
var req LikeRequest func (lh *LikeHandler) Like(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req LikeRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VideoID <= 0 {
return c.JSON(400, gin.H{"error": "video_id is required"})
} return
}
accountID, err := jwt.GetAccountID(c)
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
like := &Like{
VideoID: req.VideoID, like := &Like{
AccountID: accountID, VideoID: req.VideoID,
} AccountID: accountID,
if err := lh.service.Like(c.Request.Context(), like); err != nil { }
c.JSON(500, gin.H{"error": err.Error()}) if err := lh.service.Like(c.Request.Context(), like); err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
c.JSON(200, gin.H{"message": "like success"}) }
} c.JSON(200, gin.H{"message": "like success"})
}
func (lh *LikeHandler) Unlike(c *gin.Context) {
var req LikeRequest func (lh *LikeHandler) Unlike(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req LikeRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VideoID <= 0 {
return c.JSON(400, gin.H{"error": "video_id is required"})
} return
}
accountID, err := jwt.GetAccountID(c)
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
like := &Like{
VideoID: req.VideoID, like := &Like{
AccountID: accountID, VideoID: req.VideoID,
} AccountID: accountID,
if err := lh.service.Unlike(c.Request.Context(), like); err != nil { }
c.JSON(500, gin.H{"error": err.Error()}) if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
c.JSON(200, gin.H{"message": "unlike success"}) }
} c.JSON(200, gin.H{"message": "unlike success"})
}
func (lh *LikeHandler) IsLiked(c *gin.Context) {
var req LikeRequest func (lh *LikeHandler) IsLiked(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { var req LikeRequest
c.JSON(400, gin.H{"error": err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return 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"}) if req.VideoID <= 0 {
return c.JSON(400, gin.H{"error": "video_id is required"})
} return
}
accountID, err := jwt.GetAccountID(c)
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return 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 { isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
c.JSON(200, gin.H{"is_liked": isLiked}) }
} c.JSON(200, gin.H{"is_liked": isLiked})
}
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
accountID, err := jwt.GetAccountID(c) func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
if err != nil { accountID, err := jwt.GetAccountID(c)
c.JSON(400, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
} return
}
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
if err != nil { videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
c.JSON(500, gin.H{"error": err.Error()}) if err != nil {
return c.JSON(500, gin.H{"error": err.Error()})
} return
if videos == nil { }
videos = []Video{} if videos == nil {
} videos = []Video{}
c.JSON(200, videos) }
} c.JSON(200, videos)
}

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
"feedsystem_video_go/internal/account" "feedsystem_video_go/internal/account"
httputil "feedsystem_video_go/internal/http"
"feedsystem_video_go/internal/middleware/jwt" "feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -29,18 +30,18 @@ func NewVideoHandler(service *VideoService, accountService *account.AccountServi
func (vh *VideoHandler) PublishVideo(c *gin.Context) { func (vh *VideoHandler) PublishVideo(c *gin.Context) {
var req PublishVideoRequest var req PublishVideoRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
authorId, err := jwt.GetAccountID(c) authorId, err := jwt.GetAccountID(c)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return return
} }
username, err := jwt.GetUsername(c) username, err := jwt.GetUsername(c)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return return
} }
video := &Video{ video := &Video{
@@ -53,7 +54,7 @@ func (vh *VideoHandler) PublishVideo(c *gin.Context) {
CreateTime: time.Now(), CreateTime: time.Now(),
} }
if err := vh.service.Publish(c.Request.Context(), video); err != nil { 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 return
} }
c.JSON(200, video) c.JSON(200, video)
@@ -193,16 +194,16 @@ func buildAbsoluteURL(c *gin.Context, p string) string {
func (vh *VideoHandler) DeleteVideo(c *gin.Context) { func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
var req DeleteVideoRequest var req DeleteVideoRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
authorId, err := jwt.GetAccountID(c) authorId, err := jwt.GetAccountID(c)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return return
} }
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { 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 return
} }
c.JSON(200, gin.H{"message": "video deleted"}) 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) { func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
var req ListByAuthorIDRequest var req ListByAuthorIDRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return return
} }
if videos == nil { if videos == nil {
@@ -228,12 +229,12 @@ func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
func (vh *VideoHandler) GetDetail(c *gin.Context) { func (vh *VideoHandler) GetDetail(c *gin.Context) {
var req GetDetailRequest var req GetDetailRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
video, err := vh.service.GetDetail(c.Request.Context(), req.ID) video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": err.Error()}) c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return return
} }
c.JSON(200, video) c.JSON(200, video)
@@ -242,11 +243,11 @@ func (vh *VideoHandler) GetDetail(c *gin.Context) {
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
var req UpdateLikesCountRequest var req UpdateLikesCountRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { 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 return
} }
c.JSON(200, gin.H{"message": "likes count updated"}) c.JSON(200, gin.H{"message": "likes count updated"})

View File

@@ -1,220 +1,221 @@
package video package video
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"feedsystem_video_go/internal/middleware/rabbitmq" "feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis" rediscache "feedsystem_video_go/internal/middleware/redis"
httputil "feedsystem_video_go/internal/http"
"gorm.io/gorm"
) "gorm.io/gorm"
)
type VideoService struct {
repo *VideoRepository type VideoService struct {
cache *rediscache.Client repo *VideoRepository
cacheTTL time.Duration cache *rediscache.Client
popularityMQ *rabbitmq.PopularityMQ 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 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 { func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
return errors.New("video is nil") if video == nil {
} return errors.New("video is nil")
video.Title = strings.TrimSpace(video.Title) }
video.PlayURL = strings.TrimSpace(video.PlayURL) video.Title = strings.TrimSpace(video.Title)
video.CoverURL = strings.TrimSpace(video.CoverURL) video.PlayURL = strings.TrimSpace(video.PlayURL)
video.CoverURL = strings.TrimSpace(video.CoverURL)
if video.Title == "" {
return errors.New("title is required") if video.Title == "" {
} return errors.New("title is required")
if video.PlayURL == "" { }
return errors.New("play url is required") if video.PlayURL == "" {
} return errors.New("play url is required")
if video.CoverURL == "" { }
return errors.New("cover 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 { err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return err if err := tx.Create(video).Error; err != nil {
} return err
}
msg := OutboxMsg{
VideoID: video.ID, msg := OutboxMsg{
EventType: "video_published", VideoID: video.ID,
Status: "pending", EventType: "video_published",
CreateTime: video.CreateTime, Status: "pending",
} CreateTime: video.CreateTime,
}
if err := tx.Create(&msg).Error; err != nil {
return err if err := tx.Create(&msg).Error; err != nil {
} return err
return nil }
return nil
})
return err })
return err
}
}
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
video, err := vs.repo.GetByID(ctx, id) func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
if err != nil { video, err := vs.repo.GetByID(ctx, id)
return err if err != nil {
} return err
if video == nil { }
return errors.New("video not found") if video == nil {
} return errors.New("video not found")
if video.AuthorID != authorID { }
return errors.New("unauthorized") if video.AuthorID != authorID {
} return httputil.ErrUnauthorized
if err := vs.repo.DeleteVideo(ctx, id); err != nil { }
return err if err := vs.repo.DeleteVideo(ctx, id); err != nil {
} return err
if vs.cache != nil { }
cacheKey := fmt.Sprintf("video:detail:id=%d", id) if vs.cache != nil {
_ = vs.cache.Del(context.Background(), cacheKey) cacheKey := fmt.Sprintf("video:detail:id=%d", id)
} _ = vs.cache.Del(context.Background(), cacheKey)
return nil }
} return nil
}
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID)) func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
if err != nil { videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
return nil, err if err != nil {
} return nil, err
return videos, nil }
} return videos, nil
}
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
cacheKey := fmt.Sprintf("video:detail:id=%d", id) 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) getCached := func() (*Video, bool) {
defer cancel() opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := vs.cache.GetBytes(opCtx, cacheKey)
if err != nil { b, err := vs.cache.GetBytes(opCtx, cacheKey)
return nil, false if err != nil {
} return nil, false
var cached Video }
if err := json.Unmarshal(b, &cached); err != nil { var cached Video
return nil, false if err := json.Unmarshal(b, &cached); err != nil {
} return nil, false
return &cached, true }
} return &cached, true
}
setCached := func(video *Video) {
b, err := json.Marshal(video) setCached := func(video *Video) {
if err != nil { b, err := json.Marshal(video)
return if err != nil {
} return
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) }
defer cancel() opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) defer cancel()
} _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
}
if vs.cache != nil {
if v, ok := getCached(); ok { if vs.cache != nil {
return v, nil if v, ok := getCached(); ok {
} return v, nil
}
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
b, err := vs.cache.GetBytes(opCtx, cacheKey) opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
cancel() b, err := vs.cache.GetBytes(opCtx, cacheKey)
if err == nil { cancel()
var cached Video if err == nil {
if err := json.Unmarshal(b, &cached); err == nil { var cached Video
return &cached, nil if err := json.Unmarshal(b, &cached); err == nil {
} return &cached, nil
} else if rediscache.IsMiss(err) { }
lockKey := "lock:" + cacheKey } 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) lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
lockCancel() 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 lockErr == nil && locked {
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
if v, ok := getCached(); ok {
return v, nil if v, ok := getCached(); ok {
} return v, nil
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil { video, err := vs.repo.GetByID(ctx, id)
return nil, err if err != nil {
} return nil, err
setCached(video) }
return video, nil setCached(video)
} return video, nil
}
// 没拿到锁:等待别人回填缓存
for i := 0; i < 5; i++ { // 没拿到锁:等待别人回填缓存
select { for i := 0; i < 5; i++ {
case <-ctx.Done(): select {
return nil, ctx.Err() case <-ctx.Done():
case <-time.After(20 * time.Millisecond): return nil, ctx.Err()
} case <-time.After(20 * time.Millisecond):
if v, ok := getCached(); ok { }
return v, nil if v, ok := getCached(); ok {
} return v, nil
} }
} }
} }
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil { video, err := vs.repo.GetByID(ctx, id)
return nil, err if err != nil {
} return nil, err
if vs.cache != nil { }
setCached(video) if vs.cache != nil {
} setCached(video)
return video, nil }
} 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 { func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
return err if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
} return err
return nil }
} return nil
}
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
return err 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 { if vs.popularityMQ != nil {
return nil if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
} return nil
} }
}
if vs.cache != nil {
// 1) 详情缓存:直接失效(最简单靠谱) if vs.cache != nil {
_ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id)) // 1) 详情缓存:直接失效(最简单靠谱)
_ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id))
// 2) 热榜写到“时间窗ZSET”不要用 detail key
now := time.Now().UTC().Truncate(time.Minute) // 2) 热榜写到“时间窗ZSET”不要用 detail key
windowKey := "hot:video:1m:" + now.Format("200601021504") now := time.Now().UTC().Truncate(time.Minute)
member := strconv.FormatUint(uint64(id), 10) windowKey := "hot:video:1m:" + now.Format("200601021504")
member := strconv.FormatUint(uint64(id), 10)
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel() 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) _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
} _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
return nil }
} return nil
}