feat(P3): 私信 + #话题标签 + @提及 完成
This commit is contained in:
@@ -174,3 +174,28 @@ func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
|
|||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *FeedHandler) ListByTag(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TagName == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "tag_name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Limit <= 0 || req.Limit > 50 {
|
||||||
|
req.Limit = 10
|
||||||
|
}
|
||||||
|
viewerAccountID, _ := jwt.GetAccountID(c)
|
||||||
|
items, err := h.service.ListByTag(c.Request.Context(), req.TagName, req.Limit, viewerAccountID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"video_list": nonNilFeedVideoItems(items)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,3 +101,15 @@ func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.
|
|||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) {
|
||||||
|
var videos []*video.Video
|
||||||
|
err := repo.db.WithContext(ctx).Model(&video.Video{}).Table("videos").
|
||||||
|
Joins("JOIN video_tags ON video_tags.video_id = videos.id").
|
||||||
|
Joins("JOIN tags ON tags.id = video_tags.tag_id").
|
||||||
|
Where("tags.name = ?", tagName).
|
||||||
|
Order("videos.create_time desc").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&videos).Error
|
||||||
|
return videos, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -545,3 +545,11 @@ func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*vid
|
|||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FeedService) ListByTag(ctx context.Context, tagName string, limit int, viewerAccountID uint) ([]FeedVideoItem, error) {
|
||||||
|
videos, err := f.repo.ListByTag(ctx, tagName, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.buildFeedVideos(ctx, videos, viewerAccountID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/feed"
|
"feedsystem_video_go/internal/feed"
|
||||||
|
"feedsystem_video_go/internal/message"
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
"feedsystem_video_go/internal/middleware/ratelimit"
|
"feedsystem_video_go/internal/middleware/ratelimit"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
@@ -166,12 +167,25 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
|||||||
feedGroup.POST("/listLatest", feedHandler.ListLatest)
|
feedGroup.POST("/listLatest", feedHandler.ListLatest)
|
||||||
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
|
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
|
||||||
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
|
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
|
||||||
|
feedGroup.POST("/listByTag", feedHandler.ListByTag)
|
||||||
}
|
}
|
||||||
protectedFeedGroup := feedGroup.Group("")
|
protectedFeedGroup := feedGroup.Group("")
|
||||||
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
{
|
{
|
||||||
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
|
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
|
||||||
}
|
}
|
||||||
|
// message
|
||||||
|
messageRepo := message.NewRepository(db)
|
||||||
|
_ = messageRepo.AutoMigrate(context.Background())
|
||||||
|
messageService := message.NewService(messageRepo)
|
||||||
|
messageHandler := message.NewHandler(messageService)
|
||||||
|
messageGroup := r.Group("/message")
|
||||||
|
protectedMessageGroup := messageGroup.Group("")
|
||||||
|
protectedMessageGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
|
{
|
||||||
|
protectedMessageGroup.POST("/send", messageHandler.Send)
|
||||||
|
protectedMessageGroup.POST("/list", messageHandler.List)
|
||||||
|
}
|
||||||
//worker
|
//worker
|
||||||
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
|
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
25
backend/internal/message/entity.go
Normal file
25
backend/internal/message/entity.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
FromID uint `gorm:"index:idx_message_from;not null" json:"from_id"`
|
||||||
|
ToID uint `gorm:"index:idx_message_to;not null" json:"to_id"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendRequest struct {
|
||||||
|
ToID uint `json:"to_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRequest struct {
|
||||||
|
PeerID uint `json:"peer_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResponse struct {
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
95
backend/internal/message/handler.go
Normal file
95
backend/internal/message/handler.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct{ db *gorm.DB }
|
||||||
|
type Service struct{ repo *Repository }
|
||||||
|
type Handler struct{ service *Service }
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||||
|
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
|
||||||
|
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
|
||||||
|
|
||||||
|
func (r *Repository) AutoMigrate(ctx context.Context) error {
|
||||||
|
return r.db.WithContext(ctx).AutoMigrate(&Message{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Send(ctx context.Context, m *Message) error {
|
||||||
|
m.Content = strings.TrimSpace(m.Content)
|
||||||
|
if m.Content == "" {
|
||||||
|
return errors.New("content is required")
|
||||||
|
}
|
||||||
|
m.CreatedAt = time.Now()
|
||||||
|
return r.db.WithContext(ctx).Create(m).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, userID, peerID uint, limit int) ([]Message, error) {
|
||||||
|
var msgs []Message
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("(from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)", userID, peerID, peerID, userID).
|
||||||
|
Order("created_at desc").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&msgs).Error
|
||||||
|
return msgs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Send(c *gin.Context) {
|
||||||
|
fromID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req SendRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ToID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "to_id and content are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := &Message{FromID: fromID, ToID: req.ToID, Content: req.Content}
|
||||||
|
if err := h.service.repo.Send(c.Request.Context(), m); err != nil {
|
||||||
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
userID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ListRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PeerID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "peer_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgs, err := h.service.repo.List(c.Request.Context(), userID, req.PeerID, 50)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []Message{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, ListResponse{Messages: msgs})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"feedsystem_video_go/internal/apierror"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -57,6 +58,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mysqlEnqueued && redisEnqueued {
|
if mysqlEnqueued && redisEnqueued {
|
||||||
|
s.notifyMentions(ctx, comment)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
|||||||
if !redisEnqueued {
|
if !redisEnqueued {
|
||||||
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
||||||
}
|
}
|
||||||
|
s.notifyMentions(ctx, comment)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,3 +118,38 @@ func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, e
|
|||||||
}
|
}
|
||||||
return s.repo.GetAllComments(ctx, videoID)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
backend/internal/video/tag_entity.go
Normal file
30
backend/internal/video/tag_entity.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;type:varchar(100);not null" json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoTag struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
VideoID uint `gorm:"index;not null"`
|
||||||
|
TagID uint `gorm:"index;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagRegex = regexp.MustCompile(`#([\p{L}\p{N}_]+)`)
|
||||||
|
|
||||||
|
func ExtractTags(text string) []string {
|
||||||
|
matches := tagRegex.FindAllStringSubmatch(text, -1)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var tags []string
|
||||||
|
for _, m := range matches {
|
||||||
|
tag := m[1]
|
||||||
|
if !seen[tag] {
|
||||||
|
seen[tag] = true
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
@@ -60,8 +60,14 @@ func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
|||||||
if err := tx.Create(&msg).Error; err != nil {
|
if err := tx.Create(&msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
|
||||||
|
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
|
return err
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user