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