move: 后端
This commit is contained in:
57
backend/cmd/main.go
Normal file
57
backend/cmd/main.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"feedsystem_video_go/internal/config"
|
||||
"feedsystem_video_go/internal/db"
|
||||
apphttp "feedsystem_video_go/internal/http"
|
||||
rediscache "feedsystem_video_go/internal/redis"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load config
|
||||
log.Printf("Loading config from configs/config.yaml")
|
||||
cfg, err := config.Load("configs/config.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Connect database
|
||||
//log.Printf("Database config: %v", cfg.Database)
|
||||
sqlDB, err := db.NewDB(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(sqlDB); err != nil {
|
||||
log.Fatalf("Failed to auto migrate database: %v", err)
|
||||
}
|
||||
defer db.CloseDB(sqlDB)
|
||||
|
||||
// Connect redis (optional, used for caching)
|
||||
cache, err := rediscache.NewFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Redis config error (cache 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 (cache disabled): %v", err)
|
||||
_ = cache.Close()
|
||||
cache = nil
|
||||
} else {
|
||||
defer cache.Close()
|
||||
log.Printf("Redis connected (cache enabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// Set router
|
||||
r := apphttp.SetRouter(sqlDB, cache)
|
||||
log.Printf("Server is running on port %d", cfg.Server.Port)
|
||||
if err := r.Run(":" + strconv.Itoa(cfg.Server.Port)); err != nil {
|
||||
log.Fatalf("Failed to run server: %v", err)
|
||||
}
|
||||
}
|
||||
46
backend/internal/account/entity.go
Normal file
46
backend/internal/account/entity.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package account
|
||||
|
||||
type Account struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"unique" json:"username"`
|
||||
Password string `json:"-"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
142
backend/internal/account/handler.go
Normal file
142
backend/internal/account/handler.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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(400, 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(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, 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(400, 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(400, 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(400, 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(400, 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(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
} else {
|
||||
c.JSON(200, gin.H{"token": token})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AccountHandler) Logout(c *gin.Context) {
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, 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 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
|
||||
}
|
||||
86
backend/internal/account/repo.go
Normal file
86
backend/internal/account/repo.go
Normal file
@@ -0,0 +1,86 @@
|
||||
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 string) error {
|
||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).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).Update("token", "").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
156
backend/internal/account/service.go
Normal file
156
backend/internal/account/service.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/auth"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
rediscache "feedsystem_video_go/internal/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, fmt.Sprintf("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, 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
|
||||
}
|
||||
// generate token
|
||||
token, err := auth.GenerateToken(account.ID, account.Username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := as.accountRepository.Login(ctx, account.ID, token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if as.cache != nil {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := as.cache.SetBytes(cacheCtx, fmt.Sprintf("account:%d", account.ID), []byte(token), 24*time.Hour); err != nil {
|
||||
log.Printf("failed to set cache: %v", err)
|
||||
}
|
||||
}
|
||||
return token, 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, fmt.Sprintf("account:%d", account.ID)); err != nil {
|
||||
log.Printf("failed to del cache: %v", err)
|
||||
}
|
||||
}
|
||||
return as.accountRepository.Logout(ctx, account.ID)
|
||||
}
|
||||
65
backend/internal/auth/jwt.go
Normal file
65
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// internal/auth/jwt.go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func jwtSecret() []byte {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "change-me-in-env"
|
||||
}
|
||||
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(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
return token.SignedString(jwtSecret())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
38
backend/internal/config/loadconfig.go
Normal file
38
backend/internal/config/loadconfig.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
}
|
||||
|
||||
func Load(filename string) (Config, error) {
|
||||
data, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
36
backend/internal/db/db.go
Normal file
36
backend/internal/db/db.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/config"
|
||||
"feedsystem_video_go/internal/social"
|
||||
"feedsystem_video_go/internal/video"
|
||||
"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{})
|
||||
}
|
||||
|
||||
func CloseDB(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
58
backend/internal/feed/entity.go
Normal file
58
backend/internal/feed/entity.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package feed
|
||||
|
||||
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"`
|
||||
}
|
||||
114
backend/internal/feed/handler.go
Normal file
114
backend/internal/feed/handler.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
"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(400, 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.Unix(req.LatestTime, 0)
|
||||
}
|
||||
viewerAccountID, err := middleware.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
|
||||
}
|
||||
c.JSON(200, feedItems)
|
||||
}
|
||||
|
||||
func (f *FeedHandler) ListLikesCount(c *gin.Context) {
|
||||
var req ListLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, 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 := middleware.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
|
||||
}
|
||||
c.JSON(200, feedItems)
|
||||
}
|
||||
|
||||
func (f *FeedHandler) ListByFollowing(c *gin.Context) {
|
||||
var req ListByFollowingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
req.Limit = 10
|
||||
}
|
||||
viewerAccountID, err := middleware.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
|
||||
}
|
||||
c.JSON(200, feedItems)
|
||||
}
|
||||
70
backend/internal/feed/repo.go
Normal file
70
backend/internal/feed/repo.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
}
|
||||
256
backend/internal/feed/service.go
Normal file
256
backend/internal/feed/service.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
rediscache "feedsystem_video_go/internal/redis"
|
||||
"feedsystem_video_go/internal/video"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FeedService struct {
|
||||
repo *FeedRepository
|
||||
likeRepo *video.LikeRepository
|
||||
cache *rediscache.Client
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewFeedService(repo *FeedRepository, likeRepo *video.LikeRepository, cache *rediscache.Client) *FeedService {
|
||||
return &FeedService{repo: repo, likeRepo: likeRepo, cache: cache, cacheTTL: 5 * time.Second}
|
||||
}
|
||||
|
||||
// 查询最新视频
|
||||
func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) {
|
||||
// 从数据库中查询最新视频
|
||||
doListLatestFromDB := func() (ListLatestResponse, error) {
|
||||
videos, err := f.repo.ListLatest(ctx, limit, latestBefore)
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
}
|
||||
var nextTime int64
|
||||
if len(videos) > 0 {
|
||||
nextTime = videos[len(videos)-1].CreateTime.Unix()
|
||||
} else {
|
||||
nextTime = 0
|
||||
}
|
||||
hasMore := len(videos) == limit
|
||||
feedVideos, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
}
|
||||
resp := ListLatestResponse{
|
||||
VideoList: feedVideos,
|
||||
NextTime: nextTime,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
// 先从缓存中查询
|
||||
var cacheKey string
|
||||
if viewerAccountID == 0 && f.cache != nil {
|
||||
before := int64(0)
|
||||
if !latestBefore.IsZero() {
|
||||
before = latestBefore.Unix()
|
||||
}
|
||||
cacheKey = fmt.Sprintf("feed:listLatest:limit=%d:before=%d", limit, before)
|
||||
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
b, err := f.cache.GetBytes(cacheCtx, cacheKey)
|
||||
if err == nil {
|
||||
var cached ListLatestResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
} else if rediscache.IsMiss(err) { // 缓存未命中
|
||||
lockKey := "lock:" + cacheKey
|
||||
// 缓存未命中,尝试加锁
|
||||
token, locked, _ := f.cache.Lock(cacheCtx, lockKey, 500*time.Millisecond)
|
||||
if locked {
|
||||
defer func() { _ = f.cache.Unlock(context.Background(), lockKey, token) }()
|
||||
if b, err := f.cache.GetBytes(cacheCtx, cacheKey); err == nil {
|
||||
var cached ListLatestResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
} else { // 缓存未命中,从数据库中查询
|
||||
resp, err := doListLatestFromDB()
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
}
|
||||
if b, err := json.Marshal(resp); err == nil {
|
||||
_ = f.cache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
} else { // 缓存未命中,其他goroutine正在查询,等待
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if b, err := f.cache.GetBytes(cacheCtx, cacheKey); err == nil {
|
||||
var cached ListLatestResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 缓存中没有查询到结果,从数据库中查询
|
||||
resp, err := doListLatestFromDB()
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
}
|
||||
// 缓存查询结果
|
||||
if cacheKey != "" {
|
||||
if b, err := json.Marshal(resp); err == nil {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = f.cache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 按照点赞数查询视频
|
||||
func (f *FeedService) ListLikesCount(ctx context.Context, limit int, cursor *LikesCountCursor, viewerAccountID uint) (ListLikesCountResponse, error) {
|
||||
videos, err := f.repo.ListLikesCountWithCursor(ctx, limit, cursor)
|
||||
if err != nil {
|
||||
return ListLikesCountResponse{}, err
|
||||
}
|
||||
hasMore := len(videos) == limit
|
||||
feedVideos, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
|
||||
if err != nil {
|
||||
return ListLikesCountResponse{}, err
|
||||
}
|
||||
resp := ListLikesCountResponse{
|
||||
VideoList: feedVideos,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
if len(videos) > 0 {
|
||||
last := videos[len(videos)-1]
|
||||
nextLikesCountBefore := last.LikesCount
|
||||
nextIDBefore := last.ID
|
||||
resp.NextLikesCountBefore = &nextLikesCountBefore
|
||||
resp.NextIDBefore = &nextIDBefore
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 按照关注列表查询视频
|
||||
func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListByFollowingResponse, error) {
|
||||
doListByFollowingFromDB := func() (ListByFollowingResponse, error) {
|
||||
videos, err := f.repo.ListByFollowing(ctx, limit, viewerAccountID, latestBefore)
|
||||
if err != nil {
|
||||
return ListByFollowingResponse{}, err
|
||||
}
|
||||
var nextTime int64
|
||||
if len(videos) > 0 {
|
||||
nextTime = videos[len(videos)-1].CreateTime.Unix()
|
||||
} else {
|
||||
nextTime = 0
|
||||
}
|
||||
hasMore := len(videos) == limit
|
||||
feedVideos, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
|
||||
if err != nil {
|
||||
return ListByFollowingResponse{}, err
|
||||
}
|
||||
resp := ListByFollowingResponse{
|
||||
VideoList: feedVideos,
|
||||
NextTime: nextTime,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
var cacheKey string
|
||||
if viewerAccountID != 0 && f.cache != nil {
|
||||
before := int64(0)
|
||||
if !latestBefore.IsZero() {
|
||||
before = latestBefore.Unix()
|
||||
}
|
||||
cacheKey = fmt.Sprintf("feed:listByFollowing:limit=%d:accountID=%d:before=%d", limit, viewerAccountID, before)
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
b, err := f.cache.GetBytes(cacheCtx, cacheKey)
|
||||
if err == nil {
|
||||
var cached ListByFollowingResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
} else if rediscache.IsMiss(err) { // 缓存未命中
|
||||
lockKey := "lock:" + cacheKey
|
||||
// 缓存未命中,尝试加锁
|
||||
token, locked, _ := f.cache.Lock(cacheCtx, lockKey, 500*time.Millisecond)
|
||||
if locked {
|
||||
defer func() { _ = f.cache.Unlock(context.Background(), lockKey, token) }()
|
||||
if b, err := f.cache.GetBytes(cacheCtx, cacheKey); err == nil {
|
||||
var cached ListByFollowingResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
} else { // 缓存未命中,从数据库中查询
|
||||
resp, err := doListByFollowingFromDB()
|
||||
if err != nil {
|
||||
return ListByFollowingResponse{}, err
|
||||
}
|
||||
if b, err := json.Marshal(resp); err == nil {
|
||||
_ = f.cache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if b, err := f.cache.GetBytes(cacheCtx, cacheKey); err == nil {
|
||||
var cached ListByFollowingResponse
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := doListByFollowingFromDB()
|
||||
if err != nil {
|
||||
return ListByFollowingResponse{}, err
|
||||
}
|
||||
if cacheKey != "" {
|
||||
if b, err := json.Marshal(resp); err == nil {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = f.cache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video, viewerAccountID uint) ([]FeedVideoItem, error) {
|
||||
feedVideos := make([]FeedVideoItem, 0, len(videos))
|
||||
videoIDs := make([]uint, len(videos))
|
||||
for i, v := range videos {
|
||||
videoIDs[i] = v.ID
|
||||
}
|
||||
likedMap, err := f.likeRepo.BatchGetLiked(ctx, videoIDs, viewerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, video := range videos {
|
||||
feedVideos = append(feedVideos, FeedVideoItem{
|
||||
ID: video.ID,
|
||||
Author: FeedAuthor{ID: video.AuthorID, Username: video.Username},
|
||||
Title: video.Title,
|
||||
Description: video.Description,
|
||||
PlayURL: video.PlayURL,
|
||||
CoverURL: video.CoverURL,
|
||||
CreateTime: video.CreateTime.Unix(),
|
||||
LikesCount: video.LikesCount,
|
||||
IsLiked: likedMap[video.ID],
|
||||
})
|
||||
}
|
||||
return feedVideos, nil
|
||||
}
|
||||
104
backend/internal/http/router.go
Normal file
104
backend/internal/http/router.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/feed"
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
rediscache "feedsystem_video_go/internal/redis"
|
||||
"feedsystem_video_go/internal/social"
|
||||
"feedsystem_video_go/internal/video"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SetRouter(db *gorm.DB, cache *rediscache.Client) *gin.Engine {
|
||||
r := gin.Default()
|
||||
// account
|
||||
accountRepository := account.NewAccountRepository(db)
|
||||
accountService := account.NewAccountService(accountRepository, cache)
|
||||
accountHandler := account.NewAccountHandler(accountService)
|
||||
accountGroup := r.Group("/account")
|
||||
{
|
||||
accountGroup.POST("/register", accountHandler.CreateAccount)
|
||||
accountGroup.POST("/login", accountHandler.Login)
|
||||
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
|
||||
accountGroup.POST("/findByID", accountHandler.FindByID)
|
||||
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
|
||||
}
|
||||
protectedAccountGroup := accountGroup.Group("")
|
||||
protectedAccountGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedAccountGroup.POST("/logout", accountHandler.Logout)
|
||||
protectedAccountGroup.POST("/rename", accountHandler.Rename)
|
||||
}
|
||||
// video
|
||||
videoRepository := video.NewVideoRepository(db)
|
||||
videoService := video.NewVideoService(videoRepository, cache)
|
||||
videoHandler := video.NewVideoHandler(videoService, accountService)
|
||||
videoGroup := r.Group("/video")
|
||||
{
|
||||
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
|
||||
videoGroup.POST("/getDetail", videoHandler.GetDetail)
|
||||
}
|
||||
protectedVideoGroup := videoGroup.Group("")
|
||||
protectedVideoGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
|
||||
}
|
||||
// like
|
||||
likeRepository := video.NewLikeRepository(db)
|
||||
likeService := video.NewLikeService(likeRepository, videoRepository)
|
||||
likeHandler := video.NewLikeHandler(likeService)
|
||||
likeGroup := r.Group("/like")
|
||||
protectedLikeGroup := likeGroup.Group("")
|
||||
protectedLikeGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedLikeGroup.POST("/like", likeHandler.Like)
|
||||
protectedLikeGroup.POST("/unlike", likeHandler.Unlike)
|
||||
protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked)
|
||||
}
|
||||
// comment
|
||||
commentRepository := video.NewCommentRepository(db)
|
||||
commentService := video.NewCommentService(commentRepository, videoRepository)
|
||||
commentHandler := video.NewCommentHandler(commentService, accountService)
|
||||
commentGroup := r.Group("/comment")
|
||||
{
|
||||
commentGroup.POST("/listAll", commentHandler.GetAllComments)
|
||||
}
|
||||
protectedCommentGroup := commentGroup.Group("")
|
||||
protectedCommentGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedCommentGroup.POST("/publish", commentHandler.PublishComment)
|
||||
protectedCommentGroup.POST("/delete", commentHandler.DeleteComment)
|
||||
}
|
||||
// social
|
||||
socialRepository := social.NewSocialRepository(db)
|
||||
socialService := social.NewSocialService(socialRepository, accountRepository)
|
||||
socialHandler := social.NewSocialHandler(socialService)
|
||||
socialGroup := r.Group("/social")
|
||||
protectedSocialGroup := socialGroup.Group("")
|
||||
protectedSocialGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedSocialGroup.POST("/follow", socialHandler.Follow)
|
||||
protectedSocialGroup.POST("/unfollow", socialHandler.Unfollow)
|
||||
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
|
||||
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
|
||||
}
|
||||
// feed
|
||||
feedRepository := feed.NewFeedRepository(db)
|
||||
feedService := feed.NewFeedService(feedRepository, likeRepository, cache)
|
||||
feedHandler := feed.NewFeedHandler(feedService)
|
||||
feedGroup := r.Group("/feed")
|
||||
feedGroup.Use(middleware.SoftJWTAuth(accountRepository, cache))
|
||||
{
|
||||
feedGroup.POST("/listLatest", feedHandler.ListLatest)
|
||||
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
|
||||
}
|
||||
protectedFeedGroup := feedGroup.Group("")
|
||||
protectedFeedGroup.Use(middleware.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
|
||||
}
|
||||
return r
|
||||
}
|
||||
127
backend/internal/middleware/jwt.go
Normal file
127
backend/internal/middleware/jwt.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// internal/middleware/jwt.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/auth"
|
||||
rediscache "feedsystem_video_go/internal/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 := fmt.Sprintf("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
|
||||
}
|
||||
105
backend/internal/redis/cache.go
Normal file
105
backend/internal/redis/cache.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
redis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewFromEnv() (*Client, error) {
|
||||
addr := os.Getenv("REDIS_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:6379"
|
||||
}
|
||||
|
||||
db := 0
|
||||
if v := os.Getenv("REDIS_DB"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db = n
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
DB: db,
|
||||
})
|
||||
return &Client{rdb: rdb}, 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 (c *Client) GetBytes(ctx context.Context, key string) ([]byte, error) {
|
||||
return c.rdb.Get(ctx, key).Bytes()
|
||||
}
|
||||
|
||||
func (c *Client) SetBytes(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
return c.rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *Client) Del(ctx context.Context, key string) error {
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func IsMiss(err error) bool {
|
||||
return err == redis.Nil
|
||||
}
|
||||
|
||||
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
|
||||
`)
|
||||
|
||||
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
|
||||
}
|
||||
33
backend/internal/social/entity.go
Normal file
33
backend/internal/social/entity.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type GetAllVloggersRequest struct {
|
||||
FollowerID uint `json:"follower_id"`
|
||||
}
|
||||
|
||||
type GetAllVloggersResponse struct {
|
||||
Vloggers []*account.Account `json:"vloggers"`
|
||||
}
|
||||
118
backend/internal/social/handler.go
Normal file
118
backend/internal/social/handler.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
"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(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VloggerID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
||||
return
|
||||
}
|
||||
FollowerID, err := middleware.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(http.StatusInternalServerError, 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(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VloggerID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
||||
return
|
||||
}
|
||||
FollowerID, err := middleware.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(http.StatusInternalServerError, 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(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
vloggerID := req.VloggerID
|
||||
if vloggerID == 0 {
|
||||
accountID, err := middleware.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(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers})
|
||||
}
|
||||
|
||||
func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
|
||||
var req GetAllVloggersRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
followerID := req.FollowerID
|
||||
if followerID == 0 {
|
||||
accountID, err := middleware.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(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers})
|
||||
}
|
||||
91
backend/internal/social/repo.go
Normal file
91
backend/internal/social/repo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
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).
|
||||
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).
|
||||
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
|
||||
}
|
||||
85
backend/internal/social/service.go
Normal file
85
backend/internal/social/service.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/account"
|
||||
)
|
||||
|
||||
type SocialService struct {
|
||||
repo *SocialRepository
|
||||
accountrepo *account.AccountRepository
|
||||
}
|
||||
|
||||
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository) *SocialService {
|
||||
return &SocialService{repo: repo, accountrepo: accountrepo}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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) 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)
|
||||
}
|
||||
25
backend/internal/video/comment_entity.go
Normal file
25
backend/internal/video/comment_entity.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package video
|
||||
|
||||
import "time"
|
||||
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"index" json:"username"`
|
||||
VideoID uint `gorm:"index" json:"video_id"`
|
||||
AuthorID uint `gorm:"index" json:"author_id"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
type PublishCommentRequest struct {
|
||||
VideoID uint `json:"video_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type DeleteCommentRequest struct {
|
||||
CommentID uint `json:"comment_id"`
|
||||
}
|
||||
|
||||
type GetAllCommentsRequest struct {
|
||||
VideoID uint `json:"video_id"`
|
||||
}
|
||||
93
backend/internal/video/comment_handler.go
Normal file
93
backend/internal/video/comment_handler.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CommentHandler struct {
|
||||
service *CommentService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
|
||||
return &CommentHandler{service: service, accountService: accountService}
|
||||
}
|
||||
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
var req PublishCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
c.JSON(400, gin.H{"error": "content is required"})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
authorId, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
comment := &Comment{
|
||||
Username: user.Username,
|
||||
VideoID: req.VideoID,
|
||||
AuthorID: authorId,
|
||||
Content: req.Content,
|
||||
}
|
||||
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "comment published successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
var req DeleteCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.CommentID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "comment_id is required"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "comment deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||
var req GetAllCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID == 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, comments)
|
||||
}
|
||||
51
backend/internal/video/comment_repo.go
Normal file
51
backend/internal/video/comment_repo.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *gorm.DB) *CommentRepository {
|
||||
return &CommentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Create(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
|
||||
return r.db.WithContext(ctx).Delete(comment).Error
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
var comments []Comment
|
||||
err := r.db.WithContext(ctx).Where("video_id = ?", videoID).Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
|
||||
var comment Comment
|
||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
51
backend/internal/video/comment_service.go
Normal file
51
backend/internal/video/comment_service.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type CommentService struct {
|
||||
repo *CommentRepository
|
||||
VideoRepository *VideoRepository
|
||||
}
|
||||
|
||||
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository) *CommentService {
|
||||
return &CommentService{repo: repo, VideoRepository: videoRepo}
|
||||
}
|
||||
|
||||
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
return s.repo.CreateComment(ctx, comment)
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
|
||||
comment, err := s.repo.GetByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if comment == nil {
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
if comment.AuthorID != accountID {
|
||||
return errors.New("permission denied")
|
||||
}
|
||||
return s.repo.DeleteComment(ctx, comment)
|
||||
}
|
||||
|
||||
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
exists, err := s.VideoRepository.IsExist(ctx, videoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, errors.New("video not found")
|
||||
}
|
||||
return s.repo.GetAllComments(ctx, videoID)
|
||||
}
|
||||
18
backend/internal/video/like_entity.go
Normal file
18
backend/internal/video/like_entity.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package video
|
||||
|
||||
import "time"
|
||||
|
||||
type Like struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
VideoID uint `gorm:"uniqueIndex:idx_like_video_account;not null" json:"video_id"`
|
||||
AccountID uint `gorm:"uniqueIndex:idx_like_video_account;not null" json:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type LikeRequest struct {
|
||||
VideoID uint `json:"video_id"`
|
||||
}
|
||||
|
||||
type LikeHandler struct {
|
||||
service *LikeService
|
||||
}
|
||||
91
backend/internal/video/like_handler.go
Normal file
91
backend/internal/video/like_handler.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewLikeHandler(service *LikeService) *LikeHandler {
|
||||
return &LikeHandler{service: service}
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Like(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "like success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
like := &Like{
|
||||
VideoID: req.VideoID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "unlike success"})
|
||||
}
|
||||
|
||||
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"is_liked": isLiked})
|
||||
}
|
||||
56
backend/internal/video/like_repo.go
Normal file
56
backend/internal/video/like_repo.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LikeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *gorm.DB) *LikeRepository {
|
||||
return &LikeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).Create(like).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
|
||||
Delete(&Like{}).Error
|
||||
}
|
||||
|
||||
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
|
||||
likeMap := make(map[uint]bool)
|
||||
if len(videoIDs) == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return likeMap, nil
|
||||
}
|
||||
var likes []Like
|
||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
|
||||
Find(&likes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, like := range likes {
|
||||
likeMap[like.VideoID] = true
|
||||
}
|
||||
return likeMap, nil
|
||||
}
|
||||
66
backend/internal/video/like_service.go
Normal file
66
backend/internal/video/like_service.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LikeService struct {
|
||||
repo *LikeRepository
|
||||
VideoRepo *VideoRepository
|
||||
}
|
||||
|
||||
func NewLikeService(repo *LikeRepository, videoRepo *VideoRepository) *LikeService {
|
||||
return &LikeService{repo: repo, VideoRepo: videoRepo}
|
||||
}
|
||||
|
||||
func isDupKey(err error) bool {
|
||||
var me *mysql.MySQLError
|
||||
return errors.As(err, &me) && me.Number == 1062
|
||||
}
|
||||
|
||||
func (s *LikeService) Like(ctx context.Context, like *Like) error {
|
||||
like.CreatedAt = time.Now()
|
||||
return s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Select("id").First(&Video{}, like.VideoID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(like).Error; err != nil {
|
||||
if isDupKey(err) {
|
||||
return errors.New("user has liked this video")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Video{}).Where("id = ?", like.VideoID).
|
||||
UpdateColumn("likes_count", gorm.Expr("likes_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LikeService) Unlike(ctx context.Context, like *Like) error {
|
||||
return s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
del := tx.Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).Delete(&Like{})
|
||||
if del.Error != nil {
|
||||
return del.Error
|
||||
}
|
||||
if del.RowsAffected == 0 {
|
||||
return errors.New("user has not liked this video")
|
||||
}
|
||||
|
||||
return tx.Model(&Video{}).Where("id = ?", like.VideoID).
|
||||
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count - 1, 0)")).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LikeService) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
||||
return s.repo.IsLiked(ctx, videoID, accountID)
|
||||
}
|
||||
39
backend/internal/video/video_entity.go
Normal file
39
backend/internal/video/video_entity.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package video
|
||||
|
||||
import "time"
|
||||
|
||||
type Video struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AuthorID uint `gorm:"index;not null" json:"author_id"`
|
||||
Username string `gorm:"type:varchar(255);not null" json:"username"`
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
||||
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
||||
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"`
|
||||
LikesCount int64 `gorm:"column:likes_count;not null;default:0" json:"likes_count"`
|
||||
}
|
||||
|
||||
type PublishVideoRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PlayURL string `json:"play_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
type DeleteVideoRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type ListByAuthorIDRequest struct {
|
||||
AuthorID uint `json:"author_id"`
|
||||
}
|
||||
|
||||
type GetDetailRequest struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateLikesCountRequest struct {
|
||||
ID uint `json:"id"`
|
||||
LikesCount int64 `json:"likes_count"`
|
||||
}
|
||||
111
backend/internal/video/video_handler.go
Normal file
111
backend/internal/video/video_handler.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VideoHandler struct {
|
||||
service *VideoService
|
||||
accountService *account.AccountService
|
||||
}
|
||||
|
||||
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
|
||||
return &VideoHandler{service: service, accountService: accountService}
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||
var req PublishVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorId, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := vh.accountService.FindByID(c.Request.Context(), authorId)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video := &Video{
|
||||
AuthorID: authorId,
|
||||
Username: user.Username,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
PlayURL: req.PlayURL,
|
||||
CoverURL: req.CoverURL,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||
var req DeleteVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorId, err := middleware.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "video deleted"})
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||
var req ListByAuthorIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, videos)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||
var req GetDetailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
}
|
||||
|
||||
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
||||
var req UpdateLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "likes count updated"})
|
||||
}
|
||||
70
backend/internal/video/video_repo.go
Normal file
70
backend/internal/video/video_repo.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
|
||||
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
|
||||
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
|
||||
var videos []Video
|
||||
if err := vr.db.WithContext(ctx).
|
||||
Where("author_id = ?", authorID).
|
||||
Order("create_time desc").
|
||||
Offset(0).
|
||||
Find(&videos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
return (*Video)(nil), err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||
Where("id = ?", id).
|
||||
Update("likes_count", likesCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||
var video Video
|
||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
168
backend/internal/video/video_service.go
Normal file
168
backend/internal/video/video_service.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rediscache "feedsystem_video_go/internal/redis"
|
||||
)
|
||||
|
||||
type VideoService struct {
|
||||
repo *VideoRepository
|
||||
cache *rediscache.Client
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewVideoService(repo *VideoRepository, cache *rediscache.Client) *VideoService {
|
||||
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute}
|
||||
}
|
||||
|
||||
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
||||
if video == nil {
|
||||
return errors.New("video is nil")
|
||||
}
|
||||
video.Title = strings.TrimSpace(video.Title)
|
||||
video.PlayURL = strings.TrimSpace(video.PlayURL)
|
||||
video.CoverURL = strings.TrimSpace(video.CoverURL)
|
||||
|
||||
if video.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if video.PlayURL == "" {
|
||||
return errors.New("play url is required")
|
||||
}
|
||||
if video.CoverURL == "" {
|
||||
return errors.New("cover url is required")
|
||||
}
|
||||
if err := vs.repo.CreateVideo(ctx, video); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if video == nil {
|
||||
return errors.New("video not found")
|
||||
}
|
||||
if video.AuthorID != authorID {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
||||
_ = vs.cache.Del(context.Background(), cacheKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
|
||||
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
||||
|
||||
getCached := func() (*Video, bool) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &cached, true
|
||||
}
|
||||
|
||||
setCached := func(video *Video) {
|
||||
b, err := json.Marshal(video)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
|
||||
}
|
||||
|
||||
if vs.cache != nil {
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||
cancel()
|
||||
if err == nil {
|
||||
var cached Video
|
||||
if err := json.Unmarshal(b, &cached); err == nil {
|
||||
return &cached, nil
|
||||
}
|
||||
} else if rediscache.IsMiss(err) {
|
||||
lockKey := "lock:" + cacheKey
|
||||
|
||||
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
|
||||
lockCancel()
|
||||
|
||||
if lockErr == nil && locked {
|
||||
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
|
||||
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setCached(video)
|
||||
return video, nil
|
||||
}
|
||||
|
||||
// 没拿到锁:等待别人回填缓存
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if v, ok := getCached(); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video, err := vs.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
setCached(video)
|
||||
}
|
||||
return video, nil
|
||||
}
|
||||
|
||||
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user