move: 后端

This commit is contained in:
Leon
2025-12-25 22:40:20 +08:00
parent 99951ec3dc
commit 9ba916643e
31 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
package video
import "time"
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"index" json:"username"`
VideoID uint `gorm:"index" json:"video_id"`
AuthorID uint `gorm:"index" json:"author_id"`
Content string `gorm:"type:text" json:"content"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
type PublishCommentRequest struct {
VideoID uint `json:"video_id"`
Content string `json:"content"`
}
type DeleteCommentRequest struct {
CommentID uint `json:"comment_id"`
}
type GetAllCommentsRequest struct {
VideoID uint `json:"video_id"`
}

View File

@@ -0,0 +1,93 @@
package video
import (
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware"
"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 := middleware.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 := middleware.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
}
c.JSON(200, comments)
}

View File

@@ -0,0 +1,51 @@
package video
import (
"context"
"gorm.io/gorm"
)
type CommentRepository struct {
db *gorm.DB
}
func NewCommentRepository(db *gorm.DB) *CommentRepository {
return &CommentRepository{db: db}
}
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Create(comment).Error
}
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Delete(comment).Error
}
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
var comments []Comment
err := r.db.WithContext(ctx).Where("video_id = ?", videoID).Find(&comments).Error
return comments, err
}
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
return true, nil
}
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &comment, nil
}

View File

@@ -0,0 +1,51 @@
package video
import (
"context"
"errors"
)
type CommentService struct {
repo *CommentRepository
VideoRepository *VideoRepository
}
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository) *CommentService {
return &CommentService{repo: repo, VideoRepository: videoRepo}
}
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
if err != nil {
return err
}
if !exists {
return errors.New("video not found")
}
return s.repo.CreateComment(ctx, comment)
}
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")
}
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)
}

View File

@@ -0,0 +1,18 @@
package video
import "time"
type Like struct {
ID uint `gorm:"primaryKey" json:"id"`
VideoID uint `gorm:"uniqueIndex:idx_like_video_account;not null" json:"video_id"`
AccountID uint `gorm:"uniqueIndex:idx_like_video_account;not null" json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
type LikeRequest struct {
VideoID uint `json:"video_id"`
}
type LikeHandler struct {
service *LikeService
}

View File

@@ -0,0 +1,91 @@
package video
import (
"feedsystem_video_go/internal/middleware"
"github.com/gin-gonic/gin"
)
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 := middleware.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 := middleware.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 := middleware.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})
}

View File

@@ -0,0 +1,56 @@
package video
import (
"context"
"gorm.io/gorm"
)
type LikeRepository struct {
db *gorm.DB
}
func NewLikeRepository(db *gorm.DB) *LikeRepository {
return &LikeRepository{db: db}
}
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).Create(like).Error
}
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
Delete(&Like{}).Error
}
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id = ? AND account_id = ?", videoID, accountID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
likeMap := make(map[uint]bool)
if len(videoIDs) == 0 {
return likeMap, nil
}
if accountID == 0 {
return likeMap, nil
}
var likes []Like
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
Find(&likes).Error
if err != nil {
return nil, err
}
for _, like := range likes {
likeMap[like.VideoID] = true
}
return likeMap, nil
}

View File

@@ -0,0 +1,66 @@
package video
import (
"context"
"errors"
"time"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
type LikeService struct {
repo *LikeRepository
VideoRepo *VideoRepository
}
func NewLikeService(repo *LikeRepository, videoRepo *VideoRepository) *LikeService {
return &LikeService{repo: repo, VideoRepo: videoRepo}
}
func isDupKey(err error) bool {
var me *mysql.MySQLError
return errors.As(err, &me) && me.Number == 1062
}
func (s *LikeService) Like(ctx context.Context, like *Like) error {
like.CreatedAt = time.Now()
return s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Select("id").First(&Video{}, like.VideoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("video not found")
}
return err
}
if err := tx.Create(like).Error; err != nil {
if isDupKey(err) {
return errors.New("user has liked this video")
}
return err
}
if err := tx.Model(&Video{}).Where("id = ?", like.VideoID).
UpdateColumn("likes_count", gorm.Expr("likes_count + 1")).Error; err != nil {
return err
}
return nil
})
}
func (s *LikeService) Unlike(ctx context.Context, like *Like) error {
return s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
del := tx.Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).Delete(&Like{})
if del.Error != nil {
return del.Error
}
if del.RowsAffected == 0 {
return errors.New("user has not liked this video")
}
return tx.Model(&Video{}).Where("id = ?", like.VideoID).
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count - 1, 0)")).Error
})
}
func (s *LikeService) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
return s.repo.IsLiked(ctx, videoID, accountID)
}

View File

@@ -0,0 +1,39 @@
package video
import "time"
type Video struct {
ID uint `gorm:"primaryKey" json:"id"`
AuthorID uint `gorm:"index;not null" json:"author_id"`
Username string `gorm:"type:varchar(255);not null" json:"username"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"`
LikesCount int64 `gorm:"column:likes_count;not null;default:0" json:"likes_count"`
}
type PublishVideoRequest struct {
Title string `json:"title"`
Description string `json:"description"`
PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"`
}
type DeleteVideoRequest struct {
ID uint `json:"id"`
}
type ListByAuthorIDRequest struct {
AuthorID uint `json:"author_id"`
}
type GetDetailRequest struct {
ID uint `json:"id"`
}
type UpdateLikesCountRequest struct {
ID uint `json:"id"`
LikesCount int64 `json:"likes_count"`
}

View File

@@ -0,0 +1,111 @@
package video
import (
"time"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware"
"github.com/gin-gonic/gin"
)
type VideoHandler struct {
service *VideoService
accountService *account.AccountService
}
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
return &VideoHandler{service: service, accountService: accountService}
}
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
var req PublishVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
authorId, err := middleware.GetAccountID(c)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
user, err := vh.accountService.FindByID(c.Request.Context(), authorId)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
video := &Video{
AuthorID: authorId,
Username: user.Username,
Title: req.Title,
Description: req.Description,
PlayURL: req.PlayURL,
CoverURL: req.CoverURL,
CreateTime: time.Now(),
}
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
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()})
return
}
authorId, err := middleware.GetAccountID(c)
if err != nil {
c.JSON(400, 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()})
return
}
c.JSON(200, gin.H{"message": "video deleted"})
}
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
var req ListByAuthorIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, 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()})
return
}
c.JSON(200, videos)
}
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()})
return
}
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
var req UpdateLikesCountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, 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()})
return
}
c.JSON(200, gin.H{"message": "likes count updated"})
}

View File

@@ -0,0 +1,70 @@
package video
import (
"context"
"errors"
"gorm.io/gorm"
)
type VideoRepository struct {
db *gorm.DB
}
func NewVideoRepository(db *gorm.DB) *VideoRepository {
return &VideoRepository{db: db}
}
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
var videos []Video
if err := vr.db.WithContext(ctx).
Where("author_id = ?", authorID).
Order("create_time desc").
Offset(0).
Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
return (*Video)(nil), err
}
return &video, nil
}
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
Update("likes_count", likesCount).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,168 @@
package video
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
rediscache "feedsystem_video_go/internal/redis"
)
type VideoService struct {
repo *VideoRepository
cache *rediscache.Client
cacheTTL time.Duration
}
func NewVideoService(repo *VideoRepository, cache *rediscache.Client) *VideoService {
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute}
}
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")
}
if err := vs.repo.CreateVideo(ctx, video); err != nil {
return err
}
return nil
}
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
}