style: 统一代码格式和行尾

This commit is contained in:
leonincs
2026-05-20 16:34:19 +08:00
parent 0b87fd94cc
commit 732e284369
68 changed files with 7784 additions and 7781 deletions

View File

@@ -1,316 +1,316 @@
package main
import (
"context"
"feedsystem_video_go/internal/config"
"feedsystem_video_go/internal/db"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/observability"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"gorm.io/gorm"
)
const (
socialExchange = "social.events"
socialQueue = "social.events"
socialBindingKey = "social.*"
likeExchange = "like.events"
likeQueue = "like.events"
likeBindingKey = "like.*"
commentExchange = "comment.events"
commentQueue = "comment.events"
commentBindingKey = "comment.*"
popularityExchange = "video.popularity.events"
popularityQueue = "video.popularity.events"
popularityBindingKey = "video.popularity.*"
)
func connectWithRetry(name string, maxRetries int, fn func() error) {
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
return
}
wait := time.Duration(1<<i) * time.Second
if wait > 30*time.Second {
wait = 30 * time.Second
}
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
time.Sleep(wait)
}
log.Fatalf("%s: 超过最大重试次数", name)
}
func main() {
// 加载配置
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "configs/config.yaml"
}
log.Printf("Loading config from %s", configPath)
cfg, usedDefault, err := config.LoadLocalDev(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if usedDefault {
log.Printf("Config File %s not found, using default local config", configPath)
} else {
log.Printf("Config loaded from file: %s", configPath)
}
// 连接数据库(带重试)
var sqlDB *gorm.DB
connectWithRetry("MySQL", 10, func() error {
var err error
sqlDB, err = db.NewDB(cfg.Database)
return err
})
defer db.CloseDB(sqlDB)
// 连接 Redis用于流行度更新
cache, err := rediscache.NewFromEnv(&cfg.Redis)
if err != nil {
log.Printf("Redis config error (popularity worker disabled): %v", err)
cache = nil
} else {
pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if err := cache.Ping(pingCtx); err != nil {
log.Printf("Redis not available (popularity worker disabled): %v", err)
_ = cache.Close()
cache = nil
} else {
defer cache.Close()
log.Printf("Redis connected (popularity worker enabled)")
}
}
// 连接 RabbitMQ带重试
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
var conn *amqp.Connection
connectWithRetry("RabbitMQ", 10, func() error {
var err error
conn, err = amqp.Dial(url)
return err
})
defer conn.Close()
// 创建 RabbitMQ 通道
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open rabbitmq channel: %v", err)
}
defer ch.Close()
// 声明 Social 交换机和队列
if err := declareSocialTopology(ch); err != nil {
log.Fatalf("Failed to declare social topology: %v", err)
}
if err := declareLikeTopology(ch); err != nil {
log.Fatalf("Failed to declare like topology: %v", err)
}
if err := declareCommentTopology(ch); err != nil {
log.Fatalf("Failed to declare comment topology: %v", err)
}
if cache != nil {
if err := declarePopularityTopology(ch); err != nil {
log.Fatalf("Failed to declare popularity topology: %v", err)
}
}
if err := ch.Qos(50, 0, false); err != nil {
log.Fatalf("Failed to set qos: %v", err)
}
repo := social.NewSocialRepository(sqlDB)
socialWorker := worker.NewSocialWorker(ch, repo, socialQueue)
videoRepo := video.NewVideoRepository(sqlDB)
likeRepo := video.NewLikeRepository(sqlDB)
commentRepo := video.NewCommentRepository(sqlDB)
likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue)
commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue)
var popularityWorker *worker.PopularityWorker
if cache != nil {
popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pprofServer, err := observability.NewPprofServer(
"Worker",
cfg.ObservabilityConfig.Pprof.Enabled,
cfg.ObservabilityConfig.Pprof.WorkerAddr,
)
if err != nil {
log.Printf("Failed to start worker pprof server: %v", err)
}
if pprofServer != nil {
defer pprofServer.Close()
}
errCh := make(chan error, 4)
log.Printf("Worker started, consuming queue=%s", socialQueue)
go func() { errCh <- socialWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", likeQueue)
go func() { errCh <- likeWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", commentQueue)
go func() { errCh <- commentWorker.Run(ctx) }()
if popularityWorker != nil {
log.Printf("Worker started, consuming queue=%s", popularityQueue)
go func() { errCh <- popularityWorker.Run(ctx) }()
}
err = <-errCh
if err != nil && err != context.Canceled {
log.Fatalf("Worker stopped: %v", err)
}
log.Printf("Worker stopped")
}
func declareSocialTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
socialExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
socialQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
if err := ch.QueueBind(
q.Name,
socialBindingKey,
socialExchange,
false,
nil,
); err != nil {
return err
}
return nil
}
func declarePopularityTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
popularityExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
popularityQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
popularityBindingKey,
popularityExchange,
false,
nil,
)
}
func declareLikeTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
likeExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
likeQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
likeBindingKey,
likeExchange,
false,
nil,
)
}
func declareCommentTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
commentExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
commentQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
commentBindingKey,
commentExchange,
false,
nil,
)
}
package main
import (
"context"
"feedsystem_video_go/internal/config"
"feedsystem_video_go/internal/db"
mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/observability"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"gorm.io/gorm"
)
const (
socialExchange = "social.events"
socialQueue = "social.events"
socialBindingKey = "social.*"
likeExchange = "like.events"
likeQueue = "like.events"
likeBindingKey = "like.*"
commentExchange = "comment.events"
commentQueue = "comment.events"
commentBindingKey = "comment.*"
popularityExchange = "video.popularity.events"
popularityQueue = "video.popularity.events"
popularityBindingKey = "video.popularity.*"
)
func connectWithRetry(name string, maxRetries int, fn func() error) {
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
return
}
wait := time.Duration(1<<i) * time.Second
if wait > 30*time.Second {
wait = 30 * time.Second
}
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
time.Sleep(wait)
}
log.Fatalf("%s: 超过最大重试次数", name)
}
func main() {
// 加载配置
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "configs/config.yaml"
}
log.Printf("Loading config from %s", configPath)
cfg, usedDefault, err := config.LoadLocalDev(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if usedDefault {
log.Printf("Config File %s not found, using default local config", configPath)
} else {
log.Printf("Config loaded from file: %s", configPath)
}
// 连接数据库(带重试)
var sqlDB *gorm.DB
connectWithRetry("MySQL", 10, func() error {
var err error
sqlDB, err = db.NewDB(cfg.Database)
return err
})
defer db.CloseDB(sqlDB)
// 连接 Redis用于流行度更新
cache, err := rediscache.NewFromEnv(&cfg.Redis)
if err != nil {
log.Printf("Redis config error (popularity worker disabled): %v", err)
cache = nil
} else {
pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if err := cache.Ping(pingCtx); err != nil {
log.Printf("Redis not available (popularity worker disabled): %v", err)
_ = cache.Close()
cache = nil
} else {
defer cache.Close()
log.Printf("Redis connected (popularity worker enabled)")
}
}
// 连接 RabbitMQ带重试
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
var conn *amqp.Connection
connectWithRetry("RabbitMQ", 10, func() error {
var err error
conn, err = amqp.Dial(url)
return err
})
defer conn.Close()
// 创建 RabbitMQ 通道
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open rabbitmq channel: %v", err)
}
defer ch.Close()
// 声明 Social 交换机和队列
if err := declareSocialTopology(ch); err != nil {
log.Fatalf("Failed to declare social topology: %v", err)
}
if err := declareLikeTopology(ch); err != nil {
log.Fatalf("Failed to declare like topology: %v", err)
}
if err := declareCommentTopology(ch); err != nil {
log.Fatalf("Failed to declare comment topology: %v", err)
}
if cache != nil {
if err := declarePopularityTopology(ch); err != nil {
log.Fatalf("Failed to declare popularity topology: %v", err)
}
}
if err := ch.Qos(50, 0, false); err != nil {
log.Fatalf("Failed to set qos: %v", err)
}
repo := social.NewSocialRepository(sqlDB)
socialWorker := worker.NewSocialWorker(ch, repo, socialQueue)
videoRepo := video.NewVideoRepository(sqlDB)
likeRepo := video.NewLikeRepository(sqlDB)
commentRepo := video.NewCommentRepository(sqlDB)
likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue)
commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue)
var popularityWorker *worker.PopularityWorker
if cache != nil {
popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pprofServer, err := observability.NewPprofServer(
"Worker",
cfg.ObservabilityConfig.Pprof.Enabled,
cfg.ObservabilityConfig.Pprof.WorkerAddr,
)
if err != nil {
log.Printf("Failed to start worker pprof server: %v", err)
}
if pprofServer != nil {
defer pprofServer.Close()
}
errCh := make(chan error, 4)
log.Printf("Worker started, consuming queue=%s", socialQueue)
go func() { errCh <- socialWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", likeQueue)
go func() { errCh <- likeWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", commentQueue)
go func() { errCh <- commentWorker.Run(ctx) }()
if popularityWorker != nil {
log.Printf("Worker started, consuming queue=%s", popularityQueue)
go func() { errCh <- popularityWorker.Run(ctx) }()
}
err = <-errCh
if err != nil && err != context.Canceled {
log.Fatalf("Worker stopped: %v", err)
}
log.Printf("Worker stopped")
}
func declareSocialTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
socialExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
socialQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
if err := ch.QueueBind(
q.Name,
socialBindingKey,
socialExchange,
false,
nil,
); err != nil {
return err
}
return nil
}
func declarePopularityTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
popularityExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
popularityQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
popularityBindingKey,
popularityExchange,
false,
nil,
)
}
func declareLikeTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
likeExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
likeQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
likeBindingKey,
likeExchange,
false,
nil,
)
}
func declareCommentTopology(ch *amqp.Channel) error {
if err := ch.ExchangeDeclare(
commentExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := ch.QueueDeclare(
commentQueue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
)
if err != nil {
return err
}
return ch.QueueBind(
q.Name,
commentBindingKey,
commentExchange,
false,
nil,
)
}

View File

@@ -1,79 +1,79 @@
package account
type Account struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
Password string `json:"-"`
Token string `json:"-"`
RefreshToken string `json:"-"`
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
}
type CreateAccountRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type RenameRequest struct {
NewUsername string `json:"new_username"`
}
type FindByIDRequest struct {
ID uint `json:"id"`
}
type FindByIDResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url,omitempty"`
Bio string `json:"bio,omitempty"`
}
type FindByUsernameRequest struct {
Username string `json:"username"`
}
type FindByUsernameResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type ChangePasswordRequest struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
AccountID uint `json:"account_id"`
Username string `json:"username"`
}
type UpdateProfileRequest struct {
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type GetProfileRequest struct {
AccountID uint `json:"account_id"`
}
type GetProfileResponse struct {
Account FindByIDResponse `json:"account"`
VideoCount int64 `json:"video_count"`
TotalLikes int64 `json:"total_likes"`
FollowerCount int64 `json:"follower_count"`
VloggerCount int64 `json:"vlogger_count"`
}
package account
type Account struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
Password string `json:"-"`
Token string `json:"-"`
RefreshToken string `json:"-"`
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
}
type CreateAccountRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type RenameRequest struct {
NewUsername string `json:"new_username"`
}
type FindByIDRequest struct {
ID uint `json:"id"`
}
type FindByIDResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url,omitempty"`
Bio string `json:"bio,omitempty"`
}
type FindByUsernameRequest struct {
Username string `json:"username"`
}
type FindByUsernameResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type ChangePasswordRequest struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
AccountID uint `json:"account_id"`
Username string `json:"username"`
}
type UpdateProfileRequest struct {
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type GetProfileRequest struct {
AccountID uint `json:"account_id"`
}
type GetProfileResponse struct {
Account FindByIDResponse `json:"account"`
VideoCount int64 `json:"video_count"`
TotalLikes int64 `json:"total_likes"`
FollowerCount int64 `json:"follower_count"`
VloggerCount int64 `json:"vlogger_count"`
}

View File

@@ -1,257 +1,257 @@
package account
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"feedsystem_video_go/internal/apierror"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccountHandler struct {
accountService *AccountService
}
func NewAccountHandler(accountService *AccountService) *AccountHandler {
return &AccountHandler{accountService: accountService}
}
func (h *AccountHandler) CreateAccount(c *gin.Context) {
var req CreateAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
Username: req.Username,
Password: req.Password,
}); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account created"})
}
func (h *AccountHandler) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
if err != nil {
if errors.Is(err, ErrNewUsernameRequired) {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrUsernameTaken) {
c.JSON(409, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "account not found"})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"token": token})
}
func (h *AccountHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
return
}
c.JSON(200, gin.H{"message": "successfully password changed"})
}
func (h *AccountHandler) FindByID(c *gin.Context) {
var req FindByIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) FindByUsername(c *gin.Context) {
var req FindByUsernameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
}
func (h *AccountHandler) Logout(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account logged out"})
}
func (h *AccountHandler) UploadAvatar(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, 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 allowed"})
return
}
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
if err := os.MkdirAll(dir, 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": err.Error()})
return
}
filename = filename + ext
absPath := filepath.Join(dir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
avatarURL := buildAbsoluteURL(c, urlPath)
if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
}
func (h *AccountHandler) UpdateProfile(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *AccountHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username})
}
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 getAccountID(c *gin.Context) (uint, error) {
value, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
id, ok := value.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return id, nil
}
package account
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"feedsystem_video_go/internal/apierror"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccountHandler struct {
accountService *AccountService
}
func NewAccountHandler(accountService *AccountService) *AccountHandler {
return &AccountHandler{accountService: accountService}
}
func (h *AccountHandler) CreateAccount(c *gin.Context) {
var req CreateAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
Username: req.Username,
Password: req.Password,
}); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account created"})
}
func (h *AccountHandler) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
if err != nil {
if errors.Is(err, ErrNewUsernameRequired) {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrUsernameTaken) {
c.JSON(409, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "account not found"})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"token": token})
}
func (h *AccountHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
return
}
c.JSON(200, gin.H{"message": "successfully password changed"})
}
func (h *AccountHandler) FindByID(c *gin.Context) {
var req FindByIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) FindByUsername(c *gin.Context) {
var req FindByUsernameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
}
func (h *AccountHandler) Logout(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account logged out"})
}
func (h *AccountHandler) UploadAvatar(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, 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 allowed"})
return
}
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
if err := os.MkdirAll(dir, 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": err.Error()})
return
}
filename = filename + ext
absPath := filepath.Join(dir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
avatarURL := buildAbsoluteURL(c, urlPath)
if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
}
func (h *AccountHandler) UpdateProfile(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *AccountHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username})
}
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 getAccountID(c *gin.Context) (uint, error) {
value, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
id, ok := value.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return id, nil
}

View File

@@ -1,106 +1,106 @@
package account
import (
"context"
"gorm.io/gorm"
)
type AccountRepository struct {
db *gorm.DB
}
func NewAccountRepository(db *gorm.DB) *AccountRepository {
return &AccountRepository{db: db}
}
func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error {
if err := ar.db.WithContext(ctx).Create(account).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error {
result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error {
return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
return err
}
return nil
})
}
func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) {
var account Account
if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil {
return nil, err
}
return &account, nil
}
func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) {
var account Account
if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) Logout(ctx context.Context, id uint) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error
}
func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error
}
func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error
}
func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) {
var accounts []*Account
if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
package account
import (
"context"
"gorm.io/gorm"
)
type AccountRepository struct {
db *gorm.DB
}
func NewAccountRepository(db *gorm.DB) *AccountRepository {
return &AccountRepository{db: db}
}
func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error {
if err := ar.db.WithContext(ctx).Create(account).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error {
result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error {
return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
return err
}
return nil
})
}
func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) {
var account Account
if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil {
return nil, err
}
return &account, nil
}
func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) {
var account Account
if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) Logout(ctx context.Context, id uint) error {
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil {
return err
}
return nil
}
func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error
}
func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error
}
func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error {
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error
}
func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) {
var accounts []*Account
if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}

View File

@@ -1,236 +1,236 @@
package account
import (
"context"
"errors"
"feedsystem_video_go/internal/auth"
"log"
"strconv"
"strings"
"time"
rediscache "feedsystem_video_go/internal/middleware/redis"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AccountService struct {
accountRepository *AccountRepository
cache *rediscache.Client
}
var (
ErrUsernameTaken = errors.New("username already exists")
ErrNewUsernameRequired = errors.New("new_username is required")
)
func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService {
return &AccountService{accountRepository: accountRepository, cache: cache}
}
func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
account.Password = string(passwordHash)
if err := as.accountRepository.CreateAccount(ctx, account); err != nil {
return err
}
return nil
}
func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) {
if newUsername == "" {
return "", ErrNewUsernameRequired
}
token, err := auth.GenerateToken(accountID, newUsername)
if err != nil {
return "", err
}
if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return "", ErrUsernameTaken
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
return "", err
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", accountID), []byte(token), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
}
return token, nil
}
func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error {
account, err := as.FindByUsername(ctx, username)
if err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil {
return err
}
if err := as.Logout(ctx, account.ID); err != nil {
return err
}
return nil
}
func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) {
if account, err := as.accountRepository.FindByID(ctx, id); err != nil {
return nil, err
} else {
return account, nil
}
}
func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) {
if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil {
return nil, err
} else {
return account, nil
}
}
func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) {
account, err := as.FindByUsername(ctx, username)
if err != nil {
return "", "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {
return "", "", err
}
accessToken, err := auth.GenerateToken(account.ID, account.Username)
if err != nil {
return "", "", err
}
refreshToken, err := auth.GenerateRefreshToken(account.ID)
if err != nil {
return "", "", err
}
if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil {
return "", "", err
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil {
log.Printf("failed to set refresh cache: %v", err)
}
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil {
log.Printf("failed to set refresh lookup: %v", err)
}
}
return accessToken, refreshToken, nil
}
func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
account, err := as.FindByID(ctx, accountID)
if err != nil {
return err
}
if account.Token == "" {
return nil
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil {
log.Printf("failed to del cache: %v", err)
}
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil {
log.Printf("failed to del refresh cache: %v", err)
}
if account.RefreshToken != "" {
as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken))
}
}
return as.accountRepository.Logout(ctx, account.ID)
}
func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL)
}
func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) {
return as.accountRepository.FindAll(ctx)
}
func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error {
updates := map[string]interface{}{}
if req.Bio != "" {
updates["bio"] = strings.TrimSpace(req.Bio)
}
if req.AvatarURL != "" {
updates["avatar_url"] = strings.TrimSpace(req.AvatarURL)
}
if len(updates) == 0 {
return errors.New("nothing to update")
}
return as.accountRepository.UpdateFields(ctx, accountID, updates)
}
func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) {
if refreshToken == "" {
return "", 0, "", errors.New("refresh token is empty")
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken))
if err == nil {
idStr := string(b)
id, parseErr := strconv.ParseUint(idStr, 10, 64)
if parseErr == nil {
account, err := as.FindByID(ctx, uint(id))
if err == nil && account != nil && account.RefreshToken == refreshToken {
newToken, err := auth.GenerateToken(account.ID, account.Username)
if err != nil {
return "", 0, "", err
}
as.accountRepository.UpdateToken(ctx, account.ID, newToken)
as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour)
return newToken, account.ID, account.Username, nil
}
}
}
}
accounts, err := as.FindAll(ctx)
if err != nil {
return "", 0, "", err
}
for _, acc := range accounts {
if acc.RefreshToken == refreshToken {
newToken, err := auth.GenerateToken(acc.ID, acc.Username)
if err != nil {
return "", 0, "", err
}
as.accountRepository.UpdateToken(ctx, acc.ID, newToken)
return newToken, acc.ID, acc.Username, nil
}
}
return "", 0, "", errors.New("invalid refresh token")
}
package account
import (
"context"
"errors"
"feedsystem_video_go/internal/auth"
"log"
"strconv"
"strings"
"time"
rediscache "feedsystem_video_go/internal/middleware/redis"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AccountService struct {
accountRepository *AccountRepository
cache *rediscache.Client
}
var (
ErrUsernameTaken = errors.New("username already exists")
ErrNewUsernameRequired = errors.New("new_username is required")
)
func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService {
return &AccountService{accountRepository: accountRepository, cache: cache}
}
func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
account.Password = string(passwordHash)
if err := as.accountRepository.CreateAccount(ctx, account); err != nil {
return err
}
return nil
}
func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) {
if newUsername == "" {
return "", ErrNewUsernameRequired
}
token, err := auth.GenerateToken(accountID, newUsername)
if err != nil {
return "", err
}
if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return "", ErrUsernameTaken
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
return "", err
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", accountID), []byte(token), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
}
return token, nil
}
func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error {
account, err := as.FindByUsername(ctx, username)
if err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil {
return err
}
if err := as.Logout(ctx, account.ID); err != nil {
return err
}
return nil
}
func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) {
if account, err := as.accountRepository.FindByID(ctx, id); err != nil {
return nil, err
} else {
return account, nil
}
}
func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) {
if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil {
return nil, err
} else {
return account, nil
}
}
func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) {
account, err := as.FindByUsername(ctx, username)
if err != nil {
return "", "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {
return "", "", err
}
accessToken, err := auth.GenerateToken(account.ID, account.Username)
if err != nil {
return "", "", err
}
refreshToken, err := auth.GenerateRefreshToken(account.ID)
if err != nil {
return "", "", err
}
if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil {
return "", "", err
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil {
log.Printf("failed to set refresh cache: %v", err)
}
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil {
log.Printf("failed to set refresh lookup: %v", err)
}
}
return accessToken, refreshToken, nil
}
func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
account, err := as.FindByID(ctx, accountID)
if err != nil {
return err
}
if account.Token == "" {
return nil
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil {
log.Printf("failed to del cache: %v", err)
}
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil {
log.Printf("failed to del refresh cache: %v", err)
}
if account.RefreshToken != "" {
as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken))
}
}
return as.accountRepository.Logout(ctx, account.ID)
}
func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL)
}
func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) {
return as.accountRepository.FindAll(ctx)
}
func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error {
updates := map[string]interface{}{}
if req.Bio != "" {
updates["bio"] = strings.TrimSpace(req.Bio)
}
if req.AvatarURL != "" {
updates["avatar_url"] = strings.TrimSpace(req.AvatarURL)
}
if len(updates) == 0 {
return errors.New("nothing to update")
}
return as.accountRepository.UpdateFields(ctx, accountID, updates)
}
func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) {
if refreshToken == "" {
return "", 0, "", errors.New("refresh token is empty")
}
if as.cache != nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken))
if err == nil {
idStr := string(b)
id, parseErr := strconv.ParseUint(idStr, 10, 64)
if parseErr == nil {
account, err := as.FindByID(ctx, uint(id))
if err == nil && account != nil && account.RefreshToken == refreshToken {
newToken, err := auth.GenerateToken(account.ID, account.Username)
if err != nil {
return "", 0, "", err
}
as.accountRepository.UpdateToken(ctx, account.ID, newToken)
as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour)
return newToken, account.ID, account.Username, nil
}
}
}
}
accounts, err := as.FindAll(ctx)
if err != nil {
return "", 0, "", err
}
for _, acc := range accounts {
if acc.RefreshToken == refreshToken {
newToken, err := auth.GenerateToken(acc.ID, acc.Username)
if err != nil {
return "", 0, "", err
}
as.accountRepository.UpdateToken(ctx, acc.ID, newToken)
return newToken, acc.ID, acc.Username, nil
}
}
return "", 0, "", errors.New("invalid refresh token")
}

View File

@@ -1,82 +1,82 @@
// internal/auth/jwt.go
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func jwtSecret() []byte {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Printf("FATAL: cannot generate JWT secret: %v", err)
return []byte("fallback-unsafe-key-change-me")
}
secret = hex.EncodeToString(b)
log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.")
}
return []byte(secret)
}
type Claims struct {
AccountID uint `json:"account_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateToken(accountID uint, username string) (string, error) {
now := time.Now()
claims := Claims{
AccountID: accountID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret())
}
func GenerateRefreshToken(accountID uint) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(token *jwt.Token) (interface{}, error) {
if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, errors.New("unexpected signing method")
}
return jwtSecret(), nil
},
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrTokenInvalidClaims
}
return claims, nil
}
// internal/auth/jwt.go
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func jwtSecret() []byte {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Printf("FATAL: cannot generate JWT secret: %v", err)
return []byte("fallback-unsafe-key-change-me")
}
secret = hex.EncodeToString(b)
log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.")
}
return []byte(secret)
}
type Claims struct {
AccountID uint `json:"account_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateToken(accountID uint, username string) (string, error) {
now := time.Now()
claims := Claims{
AccountID: accountID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret())
}
func GenerateRefreshToken(accountID uint) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(token *jwt.Token) (interface{}, error) {
if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, errors.New("unexpected signing method")
}
return jwtSecret(), nil
},
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrTokenInvalidClaims
}
return claims, nil
}

View File

@@ -1,42 +1,42 @@
package db
import (
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/config"
"feedsystem_video_go/internal/message"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{},
&social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{},
&message.Message{}, &worker.Notification{},
)
}
func CloseDB(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
package db
import (
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/config"
"feedsystem_video_go/internal/message"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{},
&social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{},
&message.Message{}, &worker.Notification{},
)
}
func CloseDB(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}

View File

@@ -1,82 +1,82 @@
package feed
import "time"
type FeedAuthor struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type FeedVideoItem struct {
ID uint `json:"id"`
Author FeedAuthor `json:"author"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"`
CreateTime int64 `json:"create_time"`
LikesCount int64 `json:"likes_count"`
IsLiked bool `json:"is_liked"`
}
type ListLatestRequest struct {
Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"`
}
type ListLatestResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"`
}
type ListLikesCountRequest struct {
Limit int `json:"limit"`
LikesCountBefore *int64 `json:"likes_count_before,omitempty"`
IDBefore *uint `json:"id_before,omitempty"`
}
type LikesCountCursor struct {
LikesCount int64
ID uint
}
type ListLikesCountResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"`
NextIDBefore *uint `json:"next_id_before,omitempty"`
HasMore bool `json:"has_more"`
}
type ListByFollowingRequest struct {
Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"`
}
type ListByFollowingResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"`
}
type ListByPopularityRequest struct {
Limit int `json:"limit"`
AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳第一页传0
Offset int `json:"offset"` // 下一页从这里开始第一页传0
LatestIDBefore *uint `json:"latest_id_before,omitempty"`
// DB fallback 用(可选)
LatestPopularity int64 `json:"latest_popularity"`
LatestBefore time.Time `json:"latest_before"`
}
type ListByPopularityResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
AsOf int64 `json:"as_of"`
NextOffset int `json:"next_offset"`
HasMore bool `json:"has_more"`
NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"`
NextLatestBefore *time.Time `json:"next_latest_before,omitempty"`
NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"`
}
package feed
import "time"
type FeedAuthor struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type FeedVideoItem struct {
ID uint `json:"id"`
Author FeedAuthor `json:"author"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"`
CreateTime int64 `json:"create_time"`
LikesCount int64 `json:"likes_count"`
IsLiked bool `json:"is_liked"`
}
type ListLatestRequest struct {
Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"`
}
type ListLatestResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"`
}
type ListLikesCountRequest struct {
Limit int `json:"limit"`
LikesCountBefore *int64 `json:"likes_count_before,omitempty"`
IDBefore *uint `json:"id_before,omitempty"`
}
type LikesCountCursor struct {
LikesCount int64
ID uint
}
type ListLikesCountResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"`
NextIDBefore *uint `json:"next_id_before,omitempty"`
HasMore bool `json:"has_more"`
}
type ListByFollowingRequest struct {
Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"`
}
type ListByFollowingResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"`
}
type ListByPopularityRequest struct {
Limit int `json:"limit"`
AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳第一页传0
Offset int `json:"offset"` // 下一页从这里开始第一页传0
LatestIDBefore *uint `json:"latest_id_before,omitempty"`
// DB fallback 用(可选)
LatestPopularity int64 `json:"latest_popularity"`
LatestBefore time.Time `json:"latest_before"`
}
type ListByPopularityResponse struct {
VideoList []FeedVideoItem `json:"video_list"`
AsOf int64 `json:"as_of"`
NextOffset int `json:"next_offset"`
HasMore bool `json:"has_more"`
NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"`
NextLatestBefore *time.Time `json:"next_latest_before,omitempty"`
NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"`
}

View File

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

View File

@@ -1,115 +1,115 @@
package feed
import (
"context"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"time"
"gorm.io/gorm"
)
type FeedRepository struct {
db *gorm.DB
}
func NewFeedRepository(db *gorm.DB) *FeedRepository {
return &FeedRepository{db: db}
}
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC")
if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("likes_count DESC, id DESC")
if cursor != nil {
query = query.Where(
"(likes_count < ?) OR (likes_count = ? AND id < ?)",
cursor.LikesCount,
cursor.LikesCount, cursor.ID,
)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC")
if viewerAccountID > 0 {
followingSubQuery := repo.db.WithContext(ctx).
Model(&social.Social{}).
Select("vlogger_id").
Where("follower_id = ?", viewerAccountID)
query = query.Where("author_id IN (?)", followingSubQuery)
}
if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("popularity DESC, create_time DESC, id DESC")
// 只有当游标完整提供时才加过滤popularity 允许为 0
if !timeBefore.IsZero() && idBefore > 0 {
query = query.Where(
"(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)",
popularityBefore,
popularityBefore, timeBefore,
popularityBefore, timeBefore, idBefore,
)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) {
var videos []*video.Video
if len(ids) == 0 {
return videos, nil
}
if err := repo.db.WithContext(ctx).Model(&video.Video{}).
Where("id IN ?", ids).Find(&videos).Error; err != nil {
return nil, err
}
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
package feed
import (
"context"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"time"
"gorm.io/gorm"
)
type FeedRepository struct {
db *gorm.DB
}
func NewFeedRepository(db *gorm.DB) *FeedRepository {
return &FeedRepository{db: db}
}
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC")
if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("likes_count DESC, id DESC")
if cursor != nil {
query = query.Where(
"(likes_count < ?) OR (likes_count = ? AND id < ?)",
cursor.LikesCount,
cursor.LikesCount, cursor.ID,
)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC")
if viewerAccountID > 0 {
followingSubQuery := repo.db.WithContext(ctx).
Model(&social.Social{}).
Select("vlogger_id").
Where("follower_id = ?", viewerAccountID)
query = query.Where("author_id IN (?)", followingSubQuery)
}
if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("popularity DESC, create_time DESC, id DESC")
// 只有当游标完整提供时才加过滤popularity 允许为 0
if !timeBefore.IsZero() && idBefore > 0 {
query = query.Where(
"(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)",
popularityBefore,
popularityBefore, timeBefore,
popularityBefore, timeBefore, idBefore,
)
}
if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) {
var videos []*video.Video
if len(ids) == 0 {
return videos, nil
}
if err := repo.db.WithContext(ctx).Model(&video.Video{}).
Where("id IN ?", ids).Find(&videos).Error; err != nil {
return nil, err
}
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
}

View File

@@ -1,242 +1,242 @@
package http
import (
"context"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/feed"
"feedsystem_video_go/internal/message"
"feedsystem_video_go/internal/middleware/jwt"
"feedsystem_video_go/internal/middleware/ratelimit"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
"log"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine {
r := gin.Default()
if err := r.SetTrustedProxies(nil); err != nil {
log.Printf("SetTrustedProxies failed: %v", err)
}
r.Static("/static", "./.run/uploads")
// rate_limit
loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP)
registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP)
likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount)
commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount)
socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount)
// account
accountRepository := account.NewAccountRepository(db)
accountService := account.NewAccountService(accountRepository, cache)
accountHandler := account.NewAccountHandler(accountService)
accountGroup := r.Group("/account")
{
accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount)
accountGroup.POST("/login", loginLimiter, accountHandler.Login)
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
accountGroup.POST("/findByID", accountHandler.FindByID)
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
accountGroup.POST("/refresh", accountHandler.Refresh)
}
protectedAccountGroup := accountGroup.Group("")
protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedAccountGroup.POST("/logout", accountHandler.Logout)
protectedAccountGroup.POST("/rename", accountHandler.Rename)
protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)
protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)
}
// video
videoRepository := video.NewVideoRepository(db)
popularityMQ, err := rabbitmq.NewPopularityMQ(rmq)
if err != nil {
log.Printf("PopularityMQ init failed (mq disabled): %v", err)
popularityMQ = nil
}
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
videoHandler := video.NewVideoHandler(videoService, accountService)
chunkHandler := video.NewChunkUploadHandler(cache)
videoGroup := r.Group("/video")
{
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
videoGroup.POST("/getDetail", videoHandler.GetDetail)
}
protectedVideoGroup := videoGroup.Group("")
protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
protectedVideoGroup.POST("/chunk/init", chunkHandler.InitChunkUpload)
protectedVideoGroup.POST("/chunk/upload", chunkHandler.UploadChunk)
protectedVideoGroup.POST("/chunk/status", chunkHandler.ChunkStatus)
protectedVideoGroup.POST("/chunk/complete", chunkHandler.CompleteChunkUpload)
}
// like
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
if err != nil {
log.Printf("LikeMQ init failed (mq disabled): %v", err)
likeMQ = nil
}
likeRepository := video.NewLikeRepository(db)
likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ)
likeHandler := video.NewLikeHandler(likeService)
likeGroup := r.Group("/like")
protectedLikeGroup := likeGroup.Group("")
protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like)
protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike)
protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked)
protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos)
}
// comment
commentRepository := video.NewCommentRepository(db)
commentMQ, err := rabbitmq.NewCommentMQ(rmq)
if err != nil {
log.Printf("CommentMQ init failed (mq disabled): %v", err)
commentMQ = nil
}
commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ)
commentHandler := video.NewCommentHandler(commentService, accountService)
commentGroup := r.Group("/comment")
{
commentGroup.POST("/listAll", commentHandler.GetAllComments)
}
protectedCommentGroup := commentGroup.Group("")
protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment)
protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment)
}
// social
socialMQ, err := rabbitmq.NewSocialMQ(rmq)
if err != nil {
log.Printf("SocialMQ init failed (mq disabled): %v", err)
socialMQ = nil
}
socialRepository := social.NewSocialRepository(db)
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ)
socialHandler := social.NewSocialHandler(socialService)
socialGroup := r.Group("/social")
protectedSocialGroup := socialGroup.Group("")
protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow)
protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow)
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts)
}
accountGroup.POST("/getProfile", func(c *gin.Context) {
var req account.GetProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if req.AccountID == 0 {
c.JSON(400, gin.H{"error": "account_id is required"})
return
}
acc, err := accountService.FindByID(c.Request.Context(), req.AccountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID)
totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID)
followerCount, _ := socialRepository.CountFollowers(c.Request.Context(), req.AccountID)
vloggerCount, _ := socialRepository.CountVloggers(c.Request.Context(), req.AccountID)
c.JSON(200, account.GetProfileResponse{
Account: account.FindByIDResponse{ID: acc.ID, Username: acc.Username, AvatarURL: acc.AvatarURL, Bio: acc.Bio},
VideoCount: videoCount, TotalLikes: totalLikes,
FollowerCount: followerCount, VloggerCount: vloggerCount,
})
})
// feed
feedRepository := feed.NewFeedRepository(db)
feedService := feed.NewFeedService(feedRepository, likeRepository, cache)
feedHandler := feed.NewFeedHandler(feedService)
feedGroup := r.Group("/feed")
feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache))
{
feedGroup.POST("/listLatest", feedHandler.ListLatest)
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
feedGroup.POST("/listByTag", feedHandler.ListByTag)
}
protectedFeedGroup := feedGroup.Group("")
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
}
// message
messageRepo := message.NewRepository(db)
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
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
if err != nil {
log.Printf("timelineMQ init failed (mq disabled): %v", err)
timelineMQ = nil
}
worker.StartOutboxPoller(db, timelineMQ)
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache)
// SSE notification
if rmq != nil && rmq.Ch != nil {
rmq.DeclareTopic("like.events", "notification.like", "like.like")
rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish")
rmq.DeclareTopic("social.events", "notification.social", "social.follow")
}
sseHub := worker.NewSSEHub(db)
notifGroup := r.Group("/notification")
notifGroup.Use(sseHub.SSERequireAuth())
sseHub.RegisterRoutes(r, notifGroup)
go func() {
if rmq != nil && rmq.Ch != nil {
hub := sseHub
ctx := context.Background()
// consume from like queue
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.like", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-like worker: %v", err)
}
}()
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.comment", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-comment worker: %v", err)
}
}()
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.social", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-social worker: %v", err)
}
}()
} else {
log.Printf("Notification SSE disabled (MQ not available)")
}
}()
return r
}
package http
import (
"context"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/feed"
"feedsystem_video_go/internal/message"
"feedsystem_video_go/internal/middleware/jwt"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/middleware/ratelimit"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video"
"feedsystem_video_go/internal/worker"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"log"
"time"
)
func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine {
r := gin.Default()
if err := r.SetTrustedProxies(nil); err != nil {
log.Printf("SetTrustedProxies failed: %v", err)
}
r.Static("/static", "./.run/uploads")
// rate_limit
loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP)
registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP)
likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount)
commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount)
socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount)
// account
accountRepository := account.NewAccountRepository(db)
accountService := account.NewAccountService(accountRepository, cache)
accountHandler := account.NewAccountHandler(accountService)
accountGroup := r.Group("/account")
{
accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount)
accountGroup.POST("/login", loginLimiter, accountHandler.Login)
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
accountGroup.POST("/findByID", accountHandler.FindByID)
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
accountGroup.POST("/refresh", accountHandler.Refresh)
}
protectedAccountGroup := accountGroup.Group("")
protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedAccountGroup.POST("/logout", accountHandler.Logout)
protectedAccountGroup.POST("/rename", accountHandler.Rename)
protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)
protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)
}
// video
videoRepository := video.NewVideoRepository(db)
popularityMQ, err := rabbitmq.NewPopularityMQ(rmq)
if err != nil {
log.Printf("PopularityMQ init failed (mq disabled): %v", err)
popularityMQ = nil
}
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
videoHandler := video.NewVideoHandler(videoService, accountService)
chunkHandler := video.NewChunkUploadHandler(cache)
videoGroup := r.Group("/video")
{
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
videoGroup.POST("/getDetail", videoHandler.GetDetail)
}
protectedVideoGroup := videoGroup.Group("")
protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
protectedVideoGroup.POST("/chunk/init", chunkHandler.InitChunkUpload)
protectedVideoGroup.POST("/chunk/upload", chunkHandler.UploadChunk)
protectedVideoGroup.POST("/chunk/status", chunkHandler.ChunkStatus)
protectedVideoGroup.POST("/chunk/complete", chunkHandler.CompleteChunkUpload)
}
// like
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
if err != nil {
log.Printf("LikeMQ init failed (mq disabled): %v", err)
likeMQ = nil
}
likeRepository := video.NewLikeRepository(db)
likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ)
likeHandler := video.NewLikeHandler(likeService)
likeGroup := r.Group("/like")
protectedLikeGroup := likeGroup.Group("")
protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like)
protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike)
protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked)
protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos)
}
// comment
commentRepository := video.NewCommentRepository(db)
commentMQ, err := rabbitmq.NewCommentMQ(rmq)
if err != nil {
log.Printf("CommentMQ init failed (mq disabled): %v", err)
commentMQ = nil
}
commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ)
commentHandler := video.NewCommentHandler(commentService, accountService)
commentGroup := r.Group("/comment")
{
commentGroup.POST("/listAll", commentHandler.GetAllComments)
}
protectedCommentGroup := commentGroup.Group("")
protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment)
protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment)
}
// social
socialMQ, err := rabbitmq.NewSocialMQ(rmq)
if err != nil {
log.Printf("SocialMQ init failed (mq disabled): %v", err)
socialMQ = nil
}
socialRepository := social.NewSocialRepository(db)
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ)
socialHandler := social.NewSocialHandler(socialService)
socialGroup := r.Group("/social")
protectedSocialGroup := socialGroup.Group("")
protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow)
protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow)
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts)
}
accountGroup.POST("/getProfile", func(c *gin.Context) {
var req account.GetProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if req.AccountID == 0 {
c.JSON(400, gin.H{"error": "account_id is required"})
return
}
acc, err := accountService.FindByID(c.Request.Context(), req.AccountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID)
totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID)
followerCount, _ := socialRepository.CountFollowers(c.Request.Context(), req.AccountID)
vloggerCount, _ := socialRepository.CountVloggers(c.Request.Context(), req.AccountID)
c.JSON(200, account.GetProfileResponse{
Account: account.FindByIDResponse{ID: acc.ID, Username: acc.Username, AvatarURL: acc.AvatarURL, Bio: acc.Bio},
VideoCount: videoCount, TotalLikes: totalLikes,
FollowerCount: followerCount, VloggerCount: vloggerCount,
})
})
// feed
feedRepository := feed.NewFeedRepository(db)
feedService := feed.NewFeedService(feedRepository, likeRepository, cache)
feedHandler := feed.NewFeedHandler(feedService)
feedGroup := r.Group("/feed")
feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache))
{
feedGroup.POST("/listLatest", feedHandler.ListLatest)
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
feedGroup.POST("/listByTag", feedHandler.ListByTag)
}
protectedFeedGroup := feedGroup.Group("")
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
}
// message
messageRepo := message.NewRepository(db)
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
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
if err != nil {
log.Printf("timelineMQ init failed (mq disabled): %v", err)
timelineMQ = nil
}
worker.StartOutboxPoller(db, timelineMQ)
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache)
// SSE notification
if rmq != nil && rmq.Ch != nil {
rmq.DeclareTopic("like.events", "notification.like", "like.like")
rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish")
rmq.DeclareTopic("social.events", "notification.social", "social.follow")
}
sseHub := worker.NewSSEHub(db)
notifGroup := r.Group("/notification")
notifGroup.Use(sseHub.SSERequireAuth())
sseHub.RegisterRoutes(r, notifGroup)
go func() {
if rmq != nil && rmq.Ch != nil {
hub := sseHub
ctx := context.Background()
// consume from like queue
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.like", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-like worker: %v", err)
}
}()
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.comment", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-comment worker: %v", err)
}
}()
go func() {
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.social", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-social worker: %v", err)
}
}()
} else {
log.Printf("Notification SSE disabled (MQ not available)")
}
}()
return r
}

View File

@@ -19,8 +19,8 @@ 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 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{})

View File

@@ -1,139 +1,139 @@
package jwt
import (
"context"
"errors"
"log"
"net/http"
"strings"
"time"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/auth"
rediscache "feedsystem_video_go/internal/middleware/redis"
"github.com/gin-gonic/gin"
)
// JWTAuth check jwt token and ensure it matches the currently stored token.
func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims, err := auth.ParseToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
check(c, claims, tokenString, accountRepo, cache)
}
}
func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims, err := auth.ParseToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
check(c, claims, tokenString, accountRepo, cache)
}
}
func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) {
key := cache.Key("account:%d", claims.AccountID)
// 先查 Redis
if cache != nil {
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
defer cancel()
b, err := cache.GetBytes(cacheCtx, key)
if err == nil {
if string(b) != tokenString {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
return
}
c.Set("accountID", claims.AccountID)
c.Set("username", claims.Username)
c.Next()
return
}
}
// Redis 故障/未启用:查 DB 兜底
accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID)
if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
return
}
if cache != nil {
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
defer cancel()
if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
}
c.Set("accountID", claims.AccountID)
c.Set("username", claims.Username)
c.Next()
}
func GetAccountID(c *gin.Context) (uint, error) {
uidValue, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
accountID, ok := uidValue.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return accountID, nil
}
func GetUsername(c *gin.Context) (string, error) {
val, exists := c.Get("username")
if !exists {
return "", errors.New("username not found")
}
username, ok := val.(string)
if !ok {
return "", errors.New("username has invalid type")
}
return username, nil
}
package jwt
import (
"context"
"errors"
"log"
"net/http"
"strings"
"time"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/auth"
rediscache "feedsystem_video_go/internal/middleware/redis"
"github.com/gin-gonic/gin"
)
// JWTAuth check jwt token and ensure it matches the currently stored token.
func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims, err := auth.ParseToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
check(c, claims, tokenString, accountRepo, cache)
}
}
func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
tokenString := parts[1]
claims, err := auth.ParseToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
check(c, claims, tokenString, accountRepo, cache)
}
}
func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) {
key := cache.Key("account:%d", claims.AccountID)
// 先查 Redis
if cache != nil {
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
defer cancel()
b, err := cache.GetBytes(cacheCtx, key)
if err == nil {
if string(b) != tokenString {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
return
}
c.Set("accountID", claims.AccountID)
c.Set("username", claims.Username)
c.Next()
return
}
}
// Redis 故障/未启用:查 DB 兜底
accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID)
if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
return
}
if cache != nil {
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
defer cancel()
if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil {
log.Printf("failed to set cache: %v", err)
}
}
c.Set("accountID", claims.AccountID)
c.Set("username", claims.Username)
c.Next()
}
func GetAccountID(c *gin.Context) (uint, error) {
uidValue, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
accountID, ok := uidValue.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return accountID, nil
}
func GetUsername(c *gin.Context) (string, error) {
val, exists := c.Get("username")
if !exists {
return "", errors.New("username not found")
}
username, ok := val.(string)
if !ok {
return "", errors.New("username has invalid type")
}
return username, nil
}

View File

@@ -68,4 +68,3 @@ func (c *CommentMQ) publish(ctx context.Context, action, routingKey string, evt
evt.OccurredAt = time.Now().UTC()
return c.PublishJSON(ctx, commentExchange, routingKey, evt)
}

View File

@@ -54,4 +54,3 @@ func (p *PopularityMQ) Update(ctx context.Context, videoID uint, change int64) e
}
return p.PublishJSON(ctx, popularityExchange, popularityUpdateRK, event)
}

View File

@@ -1,123 +1,123 @@
package rabbitmq
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"feedsystem_video_go/internal/config"
"log"
"strconv"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type RabbitMQ struct {
Conn *amqp.Connection
Ch *amqp.Channel
}
func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
if cfg == nil {
return nil, errors.New("rabbitmq config is nil")
}
url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/"
conn, err := amqp.Dial(url)
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
return nil, err
}
return &RabbitMQ{Conn: conn, Ch: ch}, nil
}
func (r *RabbitMQ) Close() error {
if r == nil || r.Ch == nil || r.Conn == nil {
return nil
}
if err := r.Ch.Close(); err != nil {
return err
}
if err := r.Conn.Close(); err != nil {
return err
}
return nil
}
func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
}
if exchange == "" || queue == "" || bindingKey == "" {
return errors.New("exchange/queue/bindingKey is required")
}
if err := r.Ch.ExchangeDeclare(
exchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := r.Ch.QueueDeclare(
queue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": DLXExchange},
)
if err != nil {
return err
}
if err := r.Ch.QueueBind(
q.Name,
bindingKey,
exchange,
false,
nil,
); err != nil {
return err
}
if err := DeclareDLX(r.Ch, queue); err != nil {
log.Printf("DLX declare failed for %s: %v", queue, err)
}
return nil
}
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
}
if exchange == "" || routingKey == "" {
return errors.New("exchange and routingKey are required")
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: b,
})
}
func newEventID(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
package rabbitmq
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"feedsystem_video_go/internal/config"
"log"
"strconv"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type RabbitMQ struct {
Conn *amqp.Connection
Ch *amqp.Channel
}
func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
if cfg == nil {
return nil, errors.New("rabbitmq config is nil")
}
url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/"
conn, err := amqp.Dial(url)
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
return nil, err
}
return &RabbitMQ{Conn: conn, Ch: ch}, nil
}
func (r *RabbitMQ) Close() error {
if r == nil || r.Ch == nil || r.Conn == nil {
return nil
}
if err := r.Ch.Close(); err != nil {
return err
}
if err := r.Conn.Close(); err != nil {
return err
}
return nil
}
func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
}
if exchange == "" || queue == "" || bindingKey == "" {
return errors.New("exchange/queue/bindingKey is required")
}
if err := r.Ch.ExchangeDeclare(
exchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return err
}
q, err := r.Ch.QueueDeclare(
queue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": DLXExchange},
)
if err != nil {
return err
}
if err := r.Ch.QueueBind(
q.Name,
bindingKey,
exchange,
false,
nil,
); err != nil {
return err
}
if err := DeclareDLX(r.Ch, queue); err != nil {
log.Printf("DLX declare failed for %s: %v", queue, err)
}
return nil
}
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
}
if exchange == "" || routingKey == "" {
return errors.New("exchange and routingKey are required")
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: b,
})
}
func newEventID(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -1,14 +1,14 @@
package ratelimit
import (
rediscache "feedsystem_video_go/internal/middleware/redis"
jwt "feedsystem_video_go/internal/middleware/jwt"
rediscache "feedsystem_video_go/internal/middleware/redis"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"strings"
"time"
"strconv"
"github.com/gin-gonic/gin"
)
type KeyFunc func(*gin.Context) (string, bool)
@@ -68,4 +68,4 @@ func KeyByAccount(c *gin.Context) (string, bool) {
return "", false
}
return strconv.FormatUint(uint64(accountID), 10), true
}
}

View File

@@ -1,115 +1,115 @@
package redis
import (
"context"
"crypto/rand"
"encoding/hex"
"feedsystem_video_go/internal/config"
"fmt"
"strconv"
"time"
redis "github.com/redis/go-redis/v9"
)
type Client struct {
rdb *redis.Client
keyPrefix string
}
const defaultKeyPrefix = "v1:"
func NewClient(rdb *redis.Client, keyPrefix string) *Client {
return &Client{rdb: rdb, keyPrefix: keyPrefix}
}
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil
}
func (c *Client) Close() error {
if c == nil || c.rdb == nil {
return nil
}
return c.rdb.Close()
}
func (c *Client) Ping(ctx context.Context) error {
if c == nil || c.rdb == nil {
return nil
}
return c.rdb.Ping(ctx).Err()
}
func IsMiss(err error) bool {
return err == redis.Nil
}
func (c *Client) Key(format string, args ...any) string {
prefix := ""
if c != nil {
prefix = c.keyPrefix
}
return prefix + fmt.Sprintf(format, args...)
}
func randToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) {
if c == nil || c.rdb == nil {
return "", false, nil
}
token, err = randToken(16)
if err != nil {
return "", false, err
}
ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result()
return token, ok, err
}
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
var incrementWithExpireScript = redis.NewScript(`
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
return count
`)
func (c *Client) Unlock(ctx context.Context, key string, token string) error {
if c == nil || c.rdb == nil {
return nil
}
_, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result()
return err
}
func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) {
if c == nil || c.rdb == nil {
return 0, nil
}
return incrementWithExpireScript.Run(
ctx,
c.rdb,
[]string{key},
expire.Milliseconds(),
).Int64()
}
package redis
import (
"context"
"crypto/rand"
"encoding/hex"
"feedsystem_video_go/internal/config"
"fmt"
"strconv"
"time"
redis "github.com/redis/go-redis/v9"
)
type Client struct {
rdb *redis.Client
keyPrefix string
}
const defaultKeyPrefix = "v1:"
func NewClient(rdb *redis.Client, keyPrefix string) *Client {
return &Client{rdb: rdb, keyPrefix: keyPrefix}
}
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil
}
func (c *Client) Close() error {
if c == nil || c.rdb == nil {
return nil
}
return c.rdb.Close()
}
func (c *Client) Ping(ctx context.Context) error {
if c == nil || c.rdb == nil {
return nil
}
return c.rdb.Ping(ctx).Err()
}
func IsMiss(err error) bool {
return err == redis.Nil
}
func (c *Client) Key(format string, args ...any) string {
prefix := ""
if c != nil {
prefix = c.keyPrefix
}
return prefix + fmt.Sprintf(format, args...)
}
func randToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) {
if c == nil || c.rdb == nil {
return "", false, nil
}
token, err = randToken(16)
if err != nil {
return "", false, err
}
ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result()
return token, ok, err
}
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
var incrementWithExpireScript = redis.NewScript(`
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
return count
`)
func (c *Client) Unlock(ctx context.Context, key string, token string) error {
if c == nil || c.rdb == nil {
return nil
}
_, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result()
return err
}
func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) {
if c == nil || c.rdb == nil {
return 0, nil
}
return incrementWithExpireScript.Run(
ctx,
c.rdb,
[]string{key},
expire.Milliseconds(),
).Int64()
}

View File

@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"testing"
)
func TestNewPprofMux(t *testing.T) {
t.Parallel()
@@ -31,7 +32,7 @@ func TestNewPprofServerWithDisabled(t *testing.T) {
func TestPprofServerCloseWithDisabledServer(t *testing.T) {
t.Parallel()
pprofServer, err := NewPprofServer("api", false, "localhost:6060")
if err != nil {
t.Fatalf("Failed to create pprof server: %v", err)
@@ -39,4 +40,4 @@ func TestPprofServerCloseWithDisabledServer(t *testing.T) {
if err := pprofServer.Close(); err != nil {
t.Fatalf("Expected no error when closing disabled pprof server, got: %v", err)
}
}
}

View File

@@ -1,40 +1,40 @@
package social
import "feedsystem_video_go/internal/account"
type Social struct {
ID uint `gorm:"primaryKey"`
FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"`
VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"`
}
type FollowRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type UnfollowRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type GetAllFollowersRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type GetAllFollowersResponse struct {
Followers []*account.Account `json:"followers"`
FollowerCount int64 `json:"follower_count"`
}
type GetAllVloggersResponse struct {
Vloggers []*account.Account `json:"vloggers"`
VloggerCount int64 `json:"vlogger_count"`
}
type SocialCounts struct {
FollowerCount int64 `json:"follower_count"`
VloggerCount int64 `json:"vlogger_count"`
package social
import "feedsystem_video_go/internal/account"
type Social struct {
ID uint `gorm:"primaryKey"`
FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"`
VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"`
}
type FollowRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type UnfollowRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type GetAllFollowersRequest struct {
VloggerID uint `json:"vlogger_id"`
}
type GetAllFollowersResponse struct {
Followers []*account.Account `json:"followers"`
FollowerCount int64 `json:"follower_count"`
}
type GetAllVloggersResponse struct {
Vloggers []*account.Account `json:"vloggers"`
VloggerCount int64 `json:"vlogger_count"`
}
type SocialCounts struct {
FollowerCount int64 `json:"follower_count"`
VloggerCount int64 `json:"vlogger_count"`
}
type GetAllVloggersRequest struct {
FollowerID uint `json:"follower_id"`
}
type GetAllVloggersRequest struct {
FollowerID uint `json:"follower_id"`
}

View File

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

View File

@@ -1,109 +1,109 @@
package social
import (
"context"
"feedsystem_video_go/internal/account"
"gorm.io/gorm"
)
type SocialRepository struct {
db *gorm.DB
}
func NewSocialRepository(db *gorm.DB) *SocialRepository {
return &SocialRepository{db: db}
}
func (r *SocialRepository) Follow(ctx context.Context, social *Social) error {
return r.db.WithContext(ctx).Create(social).Error
}
func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error {
return r.db.WithContext(ctx).
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
Delete(&Social{}).Error
}
func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
var relations []Social
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("vlogger_id = ?", VloggerID).
Limit(200).
Find(&relations).Error; err != nil {
return nil, err
}
followerIDs := make([]uint, 0, len(relations))
for _, rel := range relations {
followerIDs = append(followerIDs, rel.FollowerID)
}
if len(followerIDs) == 0 {
return []*account.Account{}, nil
}
var followers []*account.Account
if err := r.db.WithContext(ctx).
Model(&account.Account{}).
Where("id IN ?", followerIDs).
Find(&followers).Error; err != nil {
return nil, err
}
return followers, nil
}
func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
var relations []Social
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("follower_id = ?", FollowerID).
Limit(200).
Find(&relations).Error; err != nil {
return nil, err
}
vloggerIDs := make([]uint, 0, len(relations))
for _, rel := range relations {
vloggerIDs = append(vloggerIDs, rel.VloggerID)
}
if len(vloggerIDs) == 0 {
return []*account.Account{}, nil
}
var vloggers []*account.Account
if err := r.db.WithContext(ctx).
Model(&account.Account{}).
Where("id IN ?", vloggerIDs).
Find(&vloggers).Error; err != nil {
return nil, err
}
return vloggers, nil
}
func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *SocialRepository) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Social{}).Where("vlogger_id = ?", vloggerID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *SocialRepository) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Social{}).Where("follower_id = ?", followerID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
package social
import (
"context"
"feedsystem_video_go/internal/account"
"gorm.io/gorm"
)
type SocialRepository struct {
db *gorm.DB
}
func NewSocialRepository(db *gorm.DB) *SocialRepository {
return &SocialRepository{db: db}
}
func (r *SocialRepository) Follow(ctx context.Context, social *Social) error {
return r.db.WithContext(ctx).Create(social).Error
}
func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error {
return r.db.WithContext(ctx).
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
Delete(&Social{}).Error
}
func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
var relations []Social
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("vlogger_id = ?", VloggerID).
Limit(200).
Find(&relations).Error; err != nil {
return nil, err
}
followerIDs := make([]uint, 0, len(relations))
for _, rel := range relations {
followerIDs = append(followerIDs, rel.FollowerID)
}
if len(followerIDs) == 0 {
return []*account.Account{}, nil
}
var followers []*account.Account
if err := r.db.WithContext(ctx).
Model(&account.Account{}).
Where("id IN ?", followerIDs).
Find(&followers).Error; err != nil {
return nil, err
}
return followers, nil
}
func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
var relations []Social
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("follower_id = ?", FollowerID).
Limit(200).
Find(&relations).Error; err != nil {
return nil, err
}
vloggerIDs := make([]uint, 0, len(relations))
for _, rel := range relations {
vloggerIDs = append(vloggerIDs, rel.VloggerID)
}
if len(vloggerIDs) == 0 {
return []*account.Account{}, nil
}
var vloggers []*account.Account
if err := r.db.WithContext(ctx).
Model(&account.Account{}).
Where("id IN ?", vloggerIDs).
Find(&vloggers).Error; err != nil {
return nil, err
}
return vloggers, nil
}
func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&Social{}).
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *SocialRepository) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Social{}).Where("vlogger_id = ?", vloggerID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *SocialRepository) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Social{}).Where("follower_id = ?", followerID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -1,101 +1,101 @@
package social
import (
"context"
"errors"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware/rabbitmq"
)
type SocialService struct {
repo *SocialRepository
accountrepo *account.AccountRepository
socialMQ *rabbitmq.SocialMQ
}
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService {
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ}
}
func (s *SocialService) Follow(ctx context.Context, social *Social) error {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return err
}
if social.FollowerID == social.VloggerID {
return errors.New("can not follow self")
}
isFollowed, err := s.repo.IsFollowed(ctx, social)
if err != nil {
return err
}
if isFollowed {
return errors.New("already followed")
}
if s.socialMQ != nil {
s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID)
}
return s.repo.Follow(ctx, social)
}
func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return err
}
isFollowed, err := s.repo.IsFollowed(ctx, social)
if err != nil {
return err
}
if !isFollowed {
return errors.New("not followed")
}
if s.socialMQ != nil {
s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID)
}
return s.repo.Unfollow(ctx, social)
}
func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
_, err := s.accountrepo.FindByID(ctx, VloggerID)
if err != nil {
return nil, err
}
return s.repo.GetAllFollowers(ctx, VloggerID)
}
func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
_, err := s.accountrepo.FindByID(ctx, FollowerID)
if err != nil {
return nil, err
}
return s.repo.GetAllVloggers(ctx, FollowerID)
}
func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
return s.repo.CountFollowers(ctx, vloggerID)
}
func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
return s.repo.CountVloggers(ctx, followerID)
}
func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return false, err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return false, err
}
return s.repo.IsFollowed(ctx, social)
}
package social
import (
"context"
"errors"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware/rabbitmq"
)
type SocialService struct {
repo *SocialRepository
accountrepo *account.AccountRepository
socialMQ *rabbitmq.SocialMQ
}
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService {
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ}
}
func (s *SocialService) Follow(ctx context.Context, social *Social) error {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return err
}
if social.FollowerID == social.VloggerID {
return errors.New("can not follow self")
}
isFollowed, err := s.repo.IsFollowed(ctx, social)
if err != nil {
return err
}
if isFollowed {
return errors.New("already followed")
}
if s.socialMQ != nil {
s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID)
}
return s.repo.Follow(ctx, social)
}
func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return err
}
isFollowed, err := s.repo.IsFollowed(ctx, social)
if err != nil {
return err
}
if !isFollowed {
return errors.New("not followed")
}
if s.socialMQ != nil {
s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID)
}
return s.repo.Unfollow(ctx, social)
}
func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
_, err := s.accountrepo.FindByID(ctx, VloggerID)
if err != nil {
return nil, err
}
return s.repo.GetAllFollowers(ctx, VloggerID)
}
func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
_, err := s.accountrepo.FindByID(ctx, FollowerID)
if err != nil {
return nil, err
}
return s.repo.GetAllVloggers(ctx, FollowerID)
}
func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
return s.repo.CountFollowers(ctx, vloggerID)
}
func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
return s.repo.CountVloggers(ctx, followerID)
}
func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) {
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
if err != nil {
return false, err
}
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
if err != nil {
return false, err
}
return s.repo.IsFollowed(ctx, social)
}

View File

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

View File

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

View File

@@ -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(&notif)
}
}
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(&notif)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,128 +1,127 @@
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/video"
"log"
"strings"
amqp "github.com/rabbitmq/amqp091-go"
)
type CommentWorker struct {
ch *amqp.Channel
comments *video.CommentRepository
videos *video.VideoRepository
queue string
}
func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker {
return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue}
}
func (w *CommentWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.comments == nil || w.videos == nil {
return errors.New("comment worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *CommentWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.CommentEvent
if err := json.Unmarshal(body, &evt); err != nil {
return nil
}
switch evt.Action {
case "publish":
return w.applyPublish(ctx, &evt)
case "delete":
return w.applyDelete(ctx, &evt)
default:
return nil
}
}
func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error {
if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" {
return nil
}
ok, err := w.videos.IsExist(ctx, evt.VideoID)
if err != nil {
return err
}
if !ok {
return nil
}
c := &video.Comment{
Username: strings.TrimSpace(evt.Username),
VideoID: evt.VideoID,
AuthorID: evt.AuthorID,
Content: strings.TrimSpace(evt.Content),
}
if err := w.comments.CreateComment(ctx, c); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, evt.VideoID, 1)
}
func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error {
if evt == nil || evt.CommentID == 0 {
return nil
}
c, err := w.comments.GetByID(ctx, evt.CommentID)
if err != nil {
return err
}
if c == nil {
return nil
}
return w.comments.DeleteComment(ctx, c)
}
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/video"
"log"
"strings"
amqp "github.com/rabbitmq/amqp091-go"
)
type CommentWorker struct {
ch *amqp.Channel
comments *video.CommentRepository
videos *video.VideoRepository
queue string
}
func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker {
return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue}
}
func (w *CommentWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.comments == nil || w.videos == nil {
return errors.New("comment worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *CommentWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.CommentEvent
if err := json.Unmarshal(body, &evt); err != nil {
return nil
}
switch evt.Action {
case "publish":
return w.applyPublish(ctx, &evt)
case "delete":
return w.applyDelete(ctx, &evt)
default:
return nil
}
}
func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error {
if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" {
return nil
}
ok, err := w.videos.IsExist(ctx, evt.VideoID)
if err != nil {
return err
}
if !ok {
return nil
}
c := &video.Comment{
Username: strings.TrimSpace(evt.Username),
VideoID: evt.VideoID,
AuthorID: evt.AuthorID,
Content: strings.TrimSpace(evt.Content),
}
if err := w.comments.CreateComment(ctx, c); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, evt.VideoID, 1)
}
func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error {
if evt == nil || evt.CommentID == 0 {
return nil
}
c, err := w.comments.GetByID(ctx, evt.CommentID)
if err != nil {
return err
}
if c == nil {
return nil
}
return w.comments.DeleteComment(ctx, c)
}

View File

@@ -1,142 +1,142 @@
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/video"
"log"
amqp "github.com/rabbitmq/amqp091-go"
"time"
)
type LikeWorker struct {
ch *amqp.Channel
likes *video.LikeRepository
videos *video.VideoRepository
queue string
}
func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker {
return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue}
}
func (w *LikeWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.likes == nil || w.videos == nil {
return errors.New("like worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *LikeWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.LikeEvent
if err := json.Unmarshal(body, &evt); err != nil {
// 解析事件失败,直接丢弃
return nil
}
if evt.UserID == 0 || evt.VideoID == 0 {
return nil
}
switch evt.Action {
case "like":
return w.applyLike(ctx, evt.UserID, evt.VideoID)
case "unlike":
return w.applyUnlike(ctx, evt.UserID, evt.VideoID)
default:
return nil
}
}
func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error {
ok, err := w.videos.IsExist(ctx, videoID)
if err != nil {
return err
}
if !ok {
return nil
}
created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{
VideoID: videoID,
AccountID: userID,
CreatedAt: time.Now(),
})
if err != nil {
return err
}
if !created {
return nil
}
if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, videoID, 1)
}
func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error {
ok, err := w.videos.IsExist(ctx, videoID)
if err != nil {
return err
}
if !ok {
return nil
}
deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID)
if err != nil {
return err
}
if !deleted {
return nil
}
if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, videoID, -1)
}
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/video"
amqp "github.com/rabbitmq/amqp091-go"
"log"
"time"
)
type LikeWorker struct {
ch *amqp.Channel
likes *video.LikeRepository
videos *video.VideoRepository
queue string
}
func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker {
return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue}
}
func (w *LikeWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.likes == nil || w.videos == nil {
return errors.New("like worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *LikeWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.LikeEvent
if err := json.Unmarshal(body, &evt); err != nil {
// 解析事件失败,直接丢弃
return nil
}
if evt.UserID == 0 || evt.VideoID == 0 {
return nil
}
switch evt.Action {
case "like":
return w.applyLike(ctx, evt.UserID, evt.VideoID)
case "unlike":
return w.applyUnlike(ctx, evt.UserID, evt.VideoID)
default:
return nil
}
}
func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error {
ok, err := w.videos.IsExist(ctx, videoID)
if err != nil {
return err
}
if !ok {
return nil
}
created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{
VideoID: videoID,
AccountID: userID,
CreatedAt: time.Now(),
})
if err != nil {
return err
}
if !created {
return nil
}
if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, videoID, 1)
}
func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error {
ok, err := w.videos.IsExist(ctx, videoID)
if err != nil {
return err
}
if !ok {
return nil
}
deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID)
if err != nil {
return err
}
if !deleted {
return nil
}
if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil {
return err
}
return w.videos.ChangePopularity(ctx, videoID, -1)
}

View File

@@ -13,21 +13,21 @@ import (
)
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
RecipientID uint `gorm:"index;not null" json:"recipient_id"`
SenderID uint `gorm:"not null" json:"sender_id"`
Type string `gorm:"type:varchar(50);not null" json:"type"`
TargetID uint `json:"target_id"`
Content string `gorm:"type:varchar(255)" json:"content"`
IsRead bool `gorm:"default:false" json:"is_read"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey" json:"id"`
RecipientID uint `gorm:"index;not null" json:"recipient_id"`
SenderID uint `gorm:"not null" json:"sender_id"`
Type string `gorm:"type:varchar(50);not null" json:"type"`
TargetID uint `json:"target_id"`
Content string `gorm:"type:varchar(255)" json:"content"`
IsRead bool `gorm:"default:false" json:"is_read"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
type NotificationWorker struct {
ch *amqp.Channel
db *gorm.DB
queue string
hub NotificationHub
ch *amqp.Channel
db *gorm.DB
queue string
hub NotificationHub
}
type NotificationHub interface {
@@ -96,7 +96,10 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error
return nil
}
var authorID uint
w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
w.db.WithContext(ctx).Model(&struct {
ID uint
AuthorID uint
}{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
if authorID == 0 || authorID == evt.UserID {
return nil
}
@@ -111,7 +114,10 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error
return nil
}
var authorID uint
w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
w.db.WithContext(ctx).Model(&struct {
ID uint
AuthorID uint
}{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
if authorID == 0 || authorID == evt.AuthorID {
return nil
}

View File

@@ -1,85 +1,84 @@
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/video"
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
type PopularityWorker struct {
ch *amqp.Channel
cache *rediscache.Client
queue string
}
func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker {
return &PopularityWorker{ch: ch, cache: cache, queue: queue}
}
func (w *PopularityWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.cache == nil {
return errors.New("popularity worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *PopularityWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.PopularityEvent
if err := json.Unmarshal(body, &evt); err != nil {
return nil
}
if evt.VideoID == 0 || evt.Change == 0 {
return nil
}
video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change)
return nil
}
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/video"
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
type PopularityWorker struct {
ch *amqp.Channel
cache *rediscache.Client
queue string
}
func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker {
return &PopularityWorker{ch: ch, cache: cache, queue: queue}
}
func (w *PopularityWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.cache == nil {
return errors.New("popularity worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *PopularityWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.PopularityEvent
if err := json.Unmarshal(body, &evt); err != nil {
return nil
}
if evt.VideoID == 0 || evt.Change == 0 {
return nil
}
video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change)
return nil
}

View File

@@ -1,106 +1,106 @@
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/social"
"log"
"github.com/go-sql-driver/mysql"
amqp "github.com/rabbitmq/amqp091-go"
)
type SocialWorker struct {
ch *amqp.Channel
repo *social.SocialRepository
queue string
}
func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker {
return &SocialWorker{ch: ch, repo: repo, queue: queue}
}
func (w *SocialWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.repo == nil {
return errors.New("social worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *SocialWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.SocialEvent
if err := json.Unmarshal(body, &evt); err != nil {
// 解析事件失败,直接丢弃
return nil
}
if evt.FollowerID == 0 || evt.VloggerID == 0 {
return nil
}
switch evt.Action {
case "follow":
err := w.repo.Follow(ctx, &social.Social{
FollowerID: evt.FollowerID,
VloggerID: evt.VloggerID,
})
if err == nil {
return nil
}
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return nil
}
return err
case "unfollow":
return w.repo.Unfollow(ctx, &social.Social{
FollowerID: evt.FollowerID,
VloggerID: evt.VloggerID,
})
default:
return nil
}
}
package worker
import (
"context"
"encoding/json"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/social"
"log"
"github.com/go-sql-driver/mysql"
amqp "github.com/rabbitmq/amqp091-go"
)
type SocialWorker struct {
ch *amqp.Channel
repo *social.SocialRepository
queue string
}
func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker {
return &SocialWorker{ch: ch, repo: repo, queue: queue}
}
func (w *SocialWorker) Run(ctx context.Context) error {
if w == nil || w.ch == nil || w.repo == nil {
return errors.New("social worker is not initialized")
}
if w.queue == "" {
return errors.New("queue is required")
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("deliveries channel closed")
}
w.handleDelivery(ctx, d)
}
}
}
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
return
}
log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
}
func (w *SocialWorker) process(ctx context.Context, body []byte) error {
var evt rabbitmq.SocialEvent
if err := json.Unmarshal(body, &evt); err != nil {
// 解析事件失败,直接丢弃
return nil
}
if evt.FollowerID == 0 || evt.VloggerID == 0 {
return nil
}
switch evt.Action {
case "follow":
err := w.repo.Follow(ctx, &social.Social{
FollowerID: evt.FollowerID,
VloggerID: evt.VloggerID,
})
if err == nil {
return nil
}
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return nil
}
return err
case "unfollow":
return w.repo.Unfollow(ctx, &social.Social{
FollowerID: evt.FollowerID,
VloggerID: evt.VloggerID,
})
default:
return nil
}
}

View File

@@ -14,9 +14,9 @@ import (
)
type SSEHub struct {
mu sync.RWMutex
clients map[uint][]chan *Notification
db *gorm.DB
mu sync.RWMutex
clients map[uint][]chan *Notification
db *gorm.DB
}
func NewSSEHub(db *gorm.DB) *SSEHub {