style: 统一代码格式和行尾
This commit is contained in:
@@ -1,98 +1,98 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CommentHandler struct {
|
||||
service *CommentService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
|
||||
return &CommentHandler{service: service, accountService: accountService}
|
||||
}
|
||||
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
var req PublishCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
c.JSON(400, gin.H{"error": "content is required"})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
comment := &Comment{
|
||||
Username: user.Username,
|
||||
VideoID: req.VideoID,
|
||||
AuthorID: authorId,
|
||||
Content: req.Content,
|
||||
}
|
||||
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "comment published successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
var req DeleteCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.CommentID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "comment_id is required"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "comment deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||
var req GetAllCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID == 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if comments == nil {
|
||||
comments = []Comment{}
|
||||
}
|
||||
c.JSON(200, comments)
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CommentHandler struct {
|
||||
service *CommentService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
|
||||
return &CommentHandler{service: service, accountService: accountService}
|
||||
}
|
||||
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
var req PublishCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
c.JSON(400, gin.H{"error": "content is required"})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
comment := &Comment{
|
||||
Username: user.Username,
|
||||
VideoID: req.VideoID,
|
||||
AuthorID: authorId,
|
||||
Content: req.Content,
|
||||
}
|
||||
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "comment published successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
var req DeleteCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.CommentID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "comment_id is required"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "comment deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||
var req GetAllCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID == 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if comments == nil {
|
||||
comments = []Comment{}
|
||||
}
|
||||
c.JSON(200, comments)
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *gorm.DB) *CommentRepository {
|
||||
return &CommentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Create(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Delete(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
var comments []Comment
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("video_id = ?", videoID).
|
||||
Order("created_at asc").
|
||||
Limit(200).
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *gorm.DB) *CommentRepository {
|
||||
return &CommentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Create(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Delete(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
var comments []Comment
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("video_id = ?", videoID).
|
||||
Order("created_at asc").
|
||||
Limit(200).
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
|
||||
@@ -1,155 +1,155 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentService struct {
|
||||
repo *CommentRepository
|
||||
VideoRepository *VideoRepository
|
||||
cache *rediscache.Client
|
||||
commentMQ *rabbitmq.CommentMQ
|
||||
popularityMQ *rabbitmq.PopularityMQ
|
||||
}
|
||||
|
||||
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
|
||||
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
|
||||
}
|
||||
|
||||
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||
if comment == nil {
|
||||
return errors.New("comment is nil")
|
||||
}
|
||||
comment.Username = strings.TrimSpace(comment.Username)
|
||||
comment.Content = strings.TrimSpace(comment.Content)
|
||||
if comment.VideoID == 0 || comment.AuthorID == 0 {
|
||||
return errors.New("video_id and author_id are required")
|
||||
}
|
||||
if comment.Content == "" {
|
||||
return errors.New("content is required")
|
||||
}
|
||||
|
||||
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
|
||||
mysqlEnqueued := false
|
||||
redisEnqueued := false
|
||||
if s.commentMQ != nil {
|
||||
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
|
||||
mysqlEnqueued = true
|
||||
}
|
||||
}
|
||||
if s.popularityMQ != nil {
|
||||
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
|
||||
redisEnqueued = true
|
||||
}
|
||||
}
|
||||
if mysqlEnqueued && redisEnqueued {
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: direct MySQL write when comment MQ publish fails.
|
||||
if !mysqlEnqueued {
|
||||
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
|
||||
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: direct Redis update when popularity MQ publish fails.
|
||||
if !redisEnqueued {
|
||||
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
||||
}
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
|
||||
comment, err := s.repo.GetByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if comment == nil {
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
if comment.AuthorID != accountID {
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if s.commentMQ != nil {
|
||||
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.repo.DeleteComment(ctx, comment)
|
||||
}
|
||||
|
||||
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
exists, err := s.VideoRepository.IsExist(ctx, videoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, errors.New("video not found")
|
||||
}
|
||||
return s.repo.GetAllComments(ctx, videoID)
|
||||
}
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`@(\w+)`)
|
||||
|
||||
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
|
||||
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range matches {
|
||||
username := m[1]
|
||||
if seen[username] || username == comment.Username {
|
||||
continue
|
||||
}
|
||||
seen[username] = true
|
||||
var accID uint
|
||||
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
|
||||
continue
|
||||
}
|
||||
notif := struct {
|
||||
RecipientID uint
|
||||
SenderID uint
|
||||
Type string
|
||||
TargetID uint
|
||||
Content string
|
||||
}{
|
||||
RecipientID: accID,
|
||||
SenderID: comment.AuthorID,
|
||||
Type: "mention",
|
||||
TargetID: comment.VideoID,
|
||||
Content: comment.Username + " 在评论中提到了你",
|
||||
}
|
||||
s.repo.db.WithContext(ctx).Table("notifications").Create(¬if)
|
||||
}
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentService struct {
|
||||
repo *CommentRepository
|
||||
VideoRepository *VideoRepository
|
||||
cache *rediscache.Client
|
||||
commentMQ *rabbitmq.CommentMQ
|
||||
popularityMQ *rabbitmq.PopularityMQ
|
||||
}
|
||||
|
||||
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
|
||||
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
|
||||
}
|
||||
|
||||
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||
if comment == nil {
|
||||
return errors.New("comment is nil")
|
||||
}
|
||||
comment.Username = strings.TrimSpace(comment.Username)
|
||||
comment.Content = strings.TrimSpace(comment.Content)
|
||||
if comment.VideoID == 0 || comment.AuthorID == 0 {
|
||||
return errors.New("video_id and author_id are required")
|
||||
}
|
||||
if comment.Content == "" {
|
||||
return errors.New("content is required")
|
||||
}
|
||||
|
||||
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
|
||||
mysqlEnqueued := false
|
||||
redisEnqueued := false
|
||||
if s.commentMQ != nil {
|
||||
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
|
||||
mysqlEnqueued = true
|
||||
}
|
||||
}
|
||||
if s.popularityMQ != nil {
|
||||
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
|
||||
redisEnqueued = true
|
||||
}
|
||||
}
|
||||
if mysqlEnqueued && redisEnqueued {
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: direct MySQL write when comment MQ publish fails.
|
||||
if !mysqlEnqueued {
|
||||
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
|
||||
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: direct Redis update when popularity MQ publish fails.
|
||||
if !redisEnqueued {
|
||||
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
||||
}
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
|
||||
comment, err := s.repo.GetByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if comment == nil {
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
if comment.AuthorID != accountID {
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if s.commentMQ != nil {
|
||||
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.repo.DeleteComment(ctx, comment)
|
||||
}
|
||||
|
||||
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
exists, err := s.VideoRepository.IsExist(ctx, videoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, errors.New("video not found")
|
||||
}
|
||||
return s.repo.GetAllComments(ctx, videoID)
|
||||
}
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`@(\w+)`)
|
||||
|
||||
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
|
||||
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range matches {
|
||||
username := m[1]
|
||||
if seen[username] || username == comment.Username {
|
||||
continue
|
||||
}
|
||||
seen[username] = true
|
||||
var accID uint
|
||||
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
|
||||
continue
|
||||
}
|
||||
notif := struct {
|
||||
RecipientID uint
|
||||
SenderID uint
|
||||
Type string
|
||||
TargetID uint
|
||||
Content string
|
||||
}{
|
||||
RecipientID: accID,
|
||||
SenderID: comment.AuthorID,
|
||||
Type: "mention",
|
||||
TargetID: comment.VideoID,
|
||||
Content: comment.Username + " 在评论中提到了你",
|
||||
}
|
||||
s.repo.db.WithContext(ctx).Table("notifications").Create(¬if)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LikeHandler struct {
|
||||
service *LikeService
|
||||
}
|
||||
|
||||
func NewLikeHandler(service *LikeService) *LikeHandler {
|
||||
return &LikeHandler{service: service}
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Like(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "like success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "unlike success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"is_liked": isLiked})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if videos == nil {
|
||||
videos = []Video{}
|
||||
}
|
||||
c.JSON(200, videos)
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LikeHandler struct {
|
||||
service *LikeService
|
||||
}
|
||||
|
||||
func NewLikeHandler(service *LikeService) *LikeHandler {
|
||||
return &LikeHandler{service: service}
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Like(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "like success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "unlike success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"is_liked": isLiked})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if videos == nil {
|
||||
videos = []Video{}
|
||||
}
|
||||
c.JSON(200, videos)
|
||||
}
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LikeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *gorm.DB) *LikeRepository {
|
||||
return &LikeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).Create(like).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
|
||||
Delete(&Like{}).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
|
||||
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
err = r.db.WithContext(ctx).Create(like).Error
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
|
||||
if videoID == 0 || accountID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||
Delete(&Like{})
|
||||
return res.RowsAffected > 0, res.Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
|
||||
likeMap := make(map[uint]bool)
|
||||
if len(videoIDs) == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
var likes []Like
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
|
||||
Find(&likes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, like := range likes {
|
||||
likeMap[like.VideoID] = true
|
||||
}
|
||||
return likeMap, nil
|
||||
}
|
||||
|
||||
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
|
||||
var videos []Video
|
||||
if accountID == 0 {
|
||||
return videos, nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&Video{}).
|
||||
Joins("JOIN likes ON likes.video_id = videos.id").
|
||||
Where("likes.account_id = ?", accountID).
|
||||
Order("likes.created_at desc").
|
||||
Limit(200).
|
||||
Find(&videos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LikeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *gorm.DB) *LikeRepository {
|
||||
return &LikeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).Create(like).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
|
||||
Delete(&Like{}).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
|
||||
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
err = r.db.WithContext(ctx).Create(like).Error
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
|
||||
if videoID == 0 || accountID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||
Delete(&Like{})
|
||||
return res.RowsAffected > 0, res.Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
|
||||
likeMap := make(map[uint]bool)
|
||||
if len(videoIDs) == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
var likes []Like
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
|
||||
Find(&likes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, like := range likes {
|
||||
likeMap[like.VideoID] = true
|
||||
}
|
||||
return likeMap, nil
|
||||
}
|
||||
|
||||
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
|
||||
var videos []Video
|
||||
if accountID == 0 {
|
||||
return videos, nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&Video{}).
|
||||
Joins("JOIN likes ON likes.video_id = videos.id").
|
||||
Where("likes.account_id = ?", accountID).
|
||||
Order("likes.created_at desc").
|
||||
Limit(200).
|
||||
Find(&videos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
)
|
||||
|
||||
// 更新视频流行度缓存
|
||||
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
|
||||
if cache == nil || id == 0 || change == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
)
|
||||
|
||||
// 更新视频流行度缓存
|
||||
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
|
||||
if cache == nil || id == 0 || change == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
package video
|
||||
|
||||
import "time"
|
||||
|
||||
type Video struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AuthorID uint `gorm:"index;not null" json:"author_id"`
|
||||
Username string `gorm:"type:varchar(255);not null" json:"username"`
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
||||
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
||||
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"`
|
||||
LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"`
|
||||
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
|
||||
}
|
||||
|
||||
type PublishVideoRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PlayURL string `json:"play_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
type DeleteVideoRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type ListByAuthorIDRequest struct {
|
||||
AuthorID uint `json:"author_id"`
|
||||
}
|
||||
|
||||
type GetDetailRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateLikesCountRequest struct {
|
||||
ID uint `json:"id"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
}
|
||||
|
||||
type OutboxMsg struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
VideoID uint `gorm:"index"`
|
||||
EventType string `gorm:"type:varchar(50)"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime"`
|
||||
Status string `gorm:"type:varchar(50);index"`
|
||||
}
|
||||
package video
|
||||
|
||||
import "time"
|
||||
|
||||
type Video struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AuthorID uint `gorm:"index;not null" json:"author_id"`
|
||||
Username string `gorm:"type:varchar(255);not null" json:"username"`
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
||||
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
||||
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"`
|
||||
LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"`
|
||||
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
|
||||
}
|
||||
|
||||
type PublishVideoRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PlayURL string `json:"play_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
type DeleteVideoRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type ListByAuthorIDRequest struct {
|
||||
AuthorID uint `json:"author_id"`
|
||||
}
|
||||
|
||||
type GetDetailRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateLikesCountRequest struct {
|
||||
ID uint `json:"id"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
}
|
||||
|
||||
type OutboxMsg struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
VideoID uint `gorm:"index"`
|
||||
EventType string `gorm:"type:varchar(50)"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime"`
|
||||
Status string `gorm:"type:varchar(50);index"`
|
||||
}
|
||||
|
||||
@@ -1,254 +1,254 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VideoHandler struct {
|
||||
service *VideoService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
|
||||
return &VideoHandler{service: service, accountService: accountService}
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||
var req PublishVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
username, err := jwt.GetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video := &Video{
|
||||
AuthorID: authorId,
|
||||
Username: username,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
PlayURL: req.PlayURL,
|
||||
CoverURL: req.CoverURL,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 200 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
if ext != ".mp4" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
date := time.Now().Format("20060102")
|
||||
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
|
||||
root := filepath.Join(".run", "uploads")
|
||||
absDir := filepath.Join(root, relDir)
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": buildAbsoluteURL(c, urlPath),
|
||||
"play_url": buildAbsoluteURL(c, urlPath),
|
||||
})
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 10 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
date := time.Now().Format("20060102")
|
||||
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
|
||||
root := filepath.Join(".run", "uploads")
|
||||
absDir := filepath.Join(root, relDir)
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": buildAbsoluteURL(c, urlPath),
|
||||
"cover_url": buildAbsoluteURL(c, urlPath),
|
||||
})
|
||||
}
|
||||
|
||||
func randHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand.Read: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
|
||||
scheme = xf
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||
var req DeleteVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "video deleted"})
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||
var req ListByAuthorIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if videos == nil {
|
||||
videos = []Video{}
|
||||
}
|
||||
c.JSON(200, videos)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||
var req GetDetailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
||||
var req UpdateLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "likes count updated"})
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VideoHandler struct {
|
||||
service *VideoService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
|
||||
return &VideoHandler{service: service, accountService: accountService}
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||
var req PublishVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
username, err := jwt.GetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video := &Video{
|
||||
AuthorID: authorId,
|
||||
Username: username,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
PlayURL: req.PlayURL,
|
||||
CoverURL: req.CoverURL,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 200 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
if ext != ".mp4" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
date := time.Now().Format("20060102")
|
||||
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
|
||||
root := filepath.Join(".run", "uploads")
|
||||
absDir := filepath.Join(root, relDir)
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": buildAbsoluteURL(c, urlPath),
|
||||
"play_url": buildAbsoluteURL(c, urlPath),
|
||||
})
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 10 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
date := time.Now().Format("20060102")
|
||||
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
|
||||
root := filepath.Join(".run", "uploads")
|
||||
absDir := filepath.Join(root, relDir)
|
||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": buildAbsoluteURL(c, urlPath),
|
||||
"cover_url": buildAbsoluteURL(c, urlPath),
|
||||
})
|
||||
}
|
||||
|
||||
func randHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand.Read: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
|
||||
scheme = xf
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||
var req DeleteVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "video deleted"})
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||
var req ListByAuthorIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if videos == nil {
|
||||
videos = []Video{}
|
||||
}
|
||||
c.JSON(200, videos)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||
var req GetDetailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
||||
var req UpdateLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "likes count updated"})
|
||||
}
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
|
||||
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
|
||||
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
|
||||
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
|
||||
var videos []Video
|
||||
if err := vr.db.WithContext(ctx).
|
||||
Where("author_id = ?", authorID).
|
||||
Order("create_time desc").
|
||||
Limit(200).
|
||||
Find(&videos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
return (*Video)(nil), err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
Update("likes_count", likesCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var total int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
|
||||
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
|
||||
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
|
||||
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
|
||||
var videos []Video
|
||||
if err := vr.db.WithContext(ctx).
|
||||
Where("author_id = ?", authorID).
|
||||
Order("create_time desc").
|
||||
Limit(200).
|
||||
Find(&videos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
return (*Video)(nil), err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
Update("likes_count", likesCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var total int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoService struct {
|
||||
repo *VideoRepository
|
||||
cache *rediscache.Client
|
||||
cacheTTL time.Duration
|
||||
popularityMQ *rabbitmq.PopularityMQ
|
||||
}
|
||||
|
||||
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
|
||||
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
|
||||
}
|
||||
|
||||
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
||||
if video == nil {
|
||||
return errors.New("video is nil")
|
||||
}
|
||||
video.Title = strings.TrimSpace(video.Title)
|
||||
video.PlayURL = strings.TrimSpace(video.PlayURL)
|
||||
video.CoverURL = strings.TrimSpace(video.CoverURL)
|
||||
|
||||
if video.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if video.PlayURL == "" {
|
||||
return errors.New("play url is required")
|
||||
}
|
||||
if video.CoverURL == "" {
|
||||
return errors.New("cover url is required")
|
||||
}
|
||||
|
||||
//事务保证视频写入库和消息写入本地消息表的一致性
|
||||
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := OutboxMsg{
|
||||
VideoID: video.ID,
|
||||
EventType: "video_published",
|
||||
Status: "pending",
|
||||
CreateTime: video.CreateTime,
|
||||
}
|
||||
|
||||
if err := tx.Create(&msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tags := ExtractTags(video.Title + " " + video.Description)
|
||||
for _, tagName := range tags {
|
||||
var tag Tag
|
||||
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
|
||||
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if video == nil {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
if video.AuthorID != authorID {
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
_ = vs.cache.Del(context.Background(), cacheKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
|
||||
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
|
||||
getCached := func() (*Video, bool) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &cached, true
|
||||
}
|
||||
|
||||
setCached := func(video *Video) {
|
||||
b, err := json.Marshal(video)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
|
||||
}
|
||||
|
||||
if vs.cache != nil {
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
cancel()
|
||||
if err == nil {
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return &cached, nil
|
||||
}
|
||||
} else if rediscache.IsMiss(err) {
|
||||
lockKey := "lock:" + cacheKey
|
||||
|
||||
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
|
||||
lockCancel()
|
||||
|
||||
if lockErr == nil && locked {
|
||||
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
|
||||
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setCached(video)
|
||||
return video, nil
|
||||
}
|
||||
|
||||
// 没拿到锁:等待别人回填缓存
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
setCached(video)
|
||||
}
|
||||
return video, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if vs.popularityMQ != nil {
|
||||
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if vs.cache != nil {
|
||||
// 1) 详情缓存:直接失效(最简单靠谱)
|
||||
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
|
||||
|
||||
// 2) 热榜:写到“时间窗ZSET”,不要用 detail key
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoService struct {
|
||||
repo *VideoRepository
|
||||
cache *rediscache.Client
|
||||
cacheTTL time.Duration
|
||||
popularityMQ *rabbitmq.PopularityMQ
|
||||
}
|
||||
|
||||
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
|
||||
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
|
||||
}
|
||||
|
||||
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
||||
if video == nil {
|
||||
return errors.New("video is nil")
|
||||
}
|
||||
video.Title = strings.TrimSpace(video.Title)
|
||||
video.PlayURL = strings.TrimSpace(video.PlayURL)
|
||||
video.CoverURL = strings.TrimSpace(video.CoverURL)
|
||||
|
||||
if video.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if video.PlayURL == "" {
|
||||
return errors.New("play url is required")
|
||||
}
|
||||
if video.CoverURL == "" {
|
||||
return errors.New("cover url is required")
|
||||
}
|
||||
|
||||
//事务保证视频写入库和消息写入本地消息表的一致性
|
||||
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := OutboxMsg{
|
||||
VideoID: video.ID,
|
||||
EventType: "video_published",
|
||||
Status: "pending",
|
||||
CreateTime: video.CreateTime,
|
||||
}
|
||||
|
||||
if err := tx.Create(&msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tags := ExtractTags(video.Title + " " + video.Description)
|
||||
for _, tagName := range tags {
|
||||
var tag Tag
|
||||
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
|
||||
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if video == nil {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
if video.AuthorID != authorID {
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
_ = vs.cache.Del(context.Background(), cacheKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
|
||||
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
|
||||
getCached := func() (*Video, bool) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &cached, true
|
||||
}
|
||||
|
||||
setCached := func(video *Video) {
|
||||
b, err := json.Marshal(video)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
|
||||
}
|
||||
|
||||
if vs.cache != nil {
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
cancel()
|
||||
if err == nil {
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return &cached, nil
|
||||
}
|
||||
} else if rediscache.IsMiss(err) {
|
||||
lockKey := "lock:" + cacheKey
|
||||
|
||||
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
|
||||
lockCancel()
|
||||
|
||||
if lockErr == nil && locked {
|
||||
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
|
||||
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setCached(video)
|
||||
return video, nil
|
||||
}
|
||||
|
||||
// 没拿到锁:等待别人回填缓存
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
setCached(video)
|
||||
}
|
||||
return video, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if vs.popularityMQ != nil {
|
||||
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if vs.cache != nil {
|
||||
// 1) 详情缓存:直接失效(最简单靠谱)
|
||||
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
|
||||
|
||||
// 2) 热榜:写到“时间窗ZSET”,不要用 detail key
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user