style: 统一代码格式和行尾

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

View File

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

View File

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

View File

@@ -1,257 +1,257 @@
package account
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"feedsystem_video_go/internal/apierror"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccountHandler struct {
accountService *AccountService
}
func NewAccountHandler(accountService *AccountService) *AccountHandler {
return &AccountHandler{accountService: accountService}
}
func (h *AccountHandler) CreateAccount(c *gin.Context) {
var req CreateAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
Username: req.Username,
Password: req.Password,
}); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account created"})
}
func (h *AccountHandler) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
if err != nil {
if errors.Is(err, ErrNewUsernameRequired) {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrUsernameTaken) {
c.JSON(409, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "account not found"})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"token": token})
}
func (h *AccountHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
return
}
c.JSON(200, gin.H{"message": "successfully password changed"})
}
func (h *AccountHandler) FindByID(c *gin.Context) {
var req FindByIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) FindByUsername(c *gin.Context) {
var req FindByUsernameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
}
func (h *AccountHandler) Logout(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account logged out"})
}
func (h *AccountHandler) UploadAvatar(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 10 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"})
return
}
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
if err := os.MkdirAll(dir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename = filename + ext
absPath := filepath.Join(dir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
avatarURL := buildAbsoluteURL(c, urlPath)
if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
}
func (h *AccountHandler) UpdateProfile(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *AccountHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username})
}
func randHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(b), nil
}
func buildAbsoluteURL(c *gin.Context, p string) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
scheme = xf
}
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
}
func getAccountID(c *gin.Context) (uint, error) {
value, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
id, ok := value.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return id, nil
}
package account
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"feedsystem_video_go/internal/apierror"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccountHandler struct {
accountService *AccountService
}
func NewAccountHandler(accountService *AccountService) *AccountHandler {
return &AccountHandler{accountService: accountService}
}
func (h *AccountHandler) CreateAccount(c *gin.Context) {
var req CreateAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
Username: req.Username,
Password: req.Password,
}); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account created"})
}
func (h *AccountHandler) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
if err != nil {
if errors.Is(err, ErrNewUsernameRequired) {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrUsernameTaken) {
c.JSON(409, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "account not found"})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"token": token})
}
func (h *AccountHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
return
}
c.JSON(200, gin.H{"message": "successfully password changed"})
}
func (h *AccountHandler) FindByID(c *gin.Context) {
var req FindByIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) FindByUsername(c *gin.Context) {
var req FindByUsernameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
} else {
c.JSON(200, account)
}
}
func (h *AccountHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
}
func (h *AccountHandler) Logout(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "account logged out"})
}
func (h *AccountHandler) UploadAvatar(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 10 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"})
return
}
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
if err := os.MkdirAll(dir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename = filename + ext
absPath := filepath.Join(dir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
avatarURL := buildAbsoluteURL(c, urlPath)
if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
}
func (h *AccountHandler) UpdateProfile(c *gin.Context) {
accountID, err := getAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *AccountHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username})
}
func randHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(b), nil
}
func buildAbsoluteURL(c *gin.Context, p string) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
scheme = xf
}
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
}
func getAccountID(c *gin.Context) (uint, error) {
value, exists := c.Get("accountID")
if !exists {
return 0, errors.New("accountID not found")
}
id, ok := value.(uint)
if !ok {
return 0, errors.New("accountID has invalid type")
}
return id, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,8 @@ type Service struct{ repo *Repository }
type Handler struct{ service *Service }
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func (r *Repository) AutoMigrate(ctx context.Context) error {
return r.db.WithContext(ctx).AutoMigrate(&Message{})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,98 +1,98 @@
package video
import (
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
)
type CommentHandler struct {
service *CommentService
accountService *account.AccountService
}
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
return &CommentHandler{service: service, accountService: accountService}
}
func (h *CommentHandler) PublishComment(c *gin.Context) {
var req PublishCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.Content == "" {
c.JSON(400, gin.H{"error": "content is required"})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
comment := &Comment{
Username: user.Username,
VideoID: req.VideoID,
AuthorID: authorId,
Content: req.Content,
}
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "comment published successfully"})
}
func (h *CommentHandler) DeleteComment(c *gin.Context) {
var req DeleteCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.CommentID <= 0 {
c.JSON(400, gin.H{"error": "comment_id is required"})
return
}
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "comment deleted successfully"})
}
func (h *CommentHandler) GetAllComments(c *gin.Context) {
var req GetAllCommentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID == 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if comments == nil {
comments = []Comment{}
}
c.JSON(200, comments)
}
package video
import (
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
)
type CommentHandler struct {
service *CommentService
accountService *account.AccountService
}
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
return &CommentHandler{service: service, accountService: accountService}
}
func (h *CommentHandler) PublishComment(c *gin.Context) {
var req PublishCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.Content == "" {
c.JSON(400, gin.H{"error": "content is required"})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
comment := &Comment{
Username: user.Username,
VideoID: req.VideoID,
AuthorID: authorId,
Content: req.Content,
}
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "comment published successfully"})
}
func (h *CommentHandler) DeleteComment(c *gin.Context) {
var req DeleteCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.CommentID <= 0 {
c.JSON(400, gin.H{"error": "comment_id is required"})
return
}
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "comment deleted successfully"})
}
func (h *CommentHandler) GetAllComments(c *gin.Context) {
var req GetAllCommentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID == 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if comments == nil {
comments = []Comment{}
}
c.JSON(200, comments)
}

View File

@@ -1,55 +1,55 @@
package video
import (
"context"
"gorm.io/gorm"
)
type CommentRepository struct {
db *gorm.DB
}
func NewCommentRepository(db *gorm.DB) *CommentRepository {
return &CommentRepository{db: db}
}
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Create(comment).Error
}
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Delete(comment).Error
}
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
var comments []Comment
err := r.db.WithContext(ctx).
Where("video_id = ?", videoID).
Order("created_at asc").
Limit(200).
Find(&comments).Error
return comments, err
}
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
return true, nil
}
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &comment, nil
}
package video
import (
"context"
"gorm.io/gorm"
)
type CommentRepository struct {
db *gorm.DB
}
func NewCommentRepository(db *gorm.DB) *CommentRepository {
return &CommentRepository{db: db}
}
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Create(comment).Error
}
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
return r.db.WithContext(ctx).Delete(comment).Error
}
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
var comments []Comment
err := r.db.WithContext(ctx).
Where("video_id = ?", videoID).
Order("created_at asc").
Limit(200).
Find(&comments).Error
return comments, err
}
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
return true, nil
}
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
var comment Comment
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &comment, nil
}

View File

@@ -1,155 +1,155 @@
package video
import (
"context"
"errors"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/apierror"
"regexp"
"strings"
"gorm.io/gorm"
)
type CommentService struct {
repo *CommentRepository
VideoRepository *VideoRepository
cache *rediscache.Client
commentMQ *rabbitmq.CommentMQ
popularityMQ *rabbitmq.PopularityMQ
}
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
}
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
if comment == nil {
return errors.New("comment is nil")
}
comment.Username = strings.TrimSpace(comment.Username)
comment.Content = strings.TrimSpace(comment.Content)
if comment.VideoID == 0 || comment.AuthorID == 0 {
return errors.New("video_id and author_id are required")
}
if comment.Content == "" {
return errors.New("content is required")
}
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
if err != nil {
return err
}
if !exists {
return errors.New("video not found")
}
mysqlEnqueued := false
redisEnqueued := false
if s.commentMQ != nil {
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
mysqlEnqueued = true
}
}
if s.popularityMQ != nil {
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
redisEnqueued = true
}
}
if mysqlEnqueued && redisEnqueued {
s.notifyMentions(ctx, comment)
return nil
}
// Fallback: direct MySQL write when comment MQ publish fails.
if !mysqlEnqueued {
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("video not found")
}
return err
}
if err := tx.Create(comment).Error; err != nil {
return err
}
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
}); err != nil {
return err
}
}
// Fallback: direct Redis update when popularity MQ publish fails.
if !redisEnqueued {
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
}
s.notifyMentions(ctx, comment)
return nil
}
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
comment, err := s.repo.GetByID(ctx, commentID)
if err != nil {
return err
}
if comment == nil {
return errors.New("comment not found")
}
if comment.AuthorID != accountID {
return apierror.ErrUnauthorized
}
if s.commentMQ != nil {
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
return nil
}
}
return s.repo.DeleteComment(ctx, comment)
}
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
exists, err := s.VideoRepository.IsExist(ctx, videoID)
if err != nil {
return nil, err
}
if !exists {
return nil, errors.New("video not found")
}
return s.repo.GetAllComments(ctx, videoID)
}
var mentionRegex = regexp.MustCompile(`@(\w+)`)
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
if len(matches) == 0 {
return
}
seen := make(map[string]bool)
for _, m := range matches {
username := m[1]
if seen[username] || username == comment.Username {
continue
}
seen[username] = true
var accID uint
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
continue
}
notif := struct {
RecipientID uint
SenderID uint
Type string
TargetID uint
Content string
}{
RecipientID: accID,
SenderID: comment.AuthorID,
Type: "mention",
TargetID: comment.VideoID,
Content: comment.Username + " 在评论中提到了你",
}
s.repo.db.WithContext(ctx).Table("notifications").Create(&notif)
}
}
package video
import (
"context"
"errors"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"regexp"
"strings"
"gorm.io/gorm"
)
type CommentService struct {
repo *CommentRepository
VideoRepository *VideoRepository
cache *rediscache.Client
commentMQ *rabbitmq.CommentMQ
popularityMQ *rabbitmq.PopularityMQ
}
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
}
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
if comment == nil {
return errors.New("comment is nil")
}
comment.Username = strings.TrimSpace(comment.Username)
comment.Content = strings.TrimSpace(comment.Content)
if comment.VideoID == 0 || comment.AuthorID == 0 {
return errors.New("video_id and author_id are required")
}
if comment.Content == "" {
return errors.New("content is required")
}
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
if err != nil {
return err
}
if !exists {
return errors.New("video not found")
}
mysqlEnqueued := false
redisEnqueued := false
if s.commentMQ != nil {
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
mysqlEnqueued = true
}
}
if s.popularityMQ != nil {
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
redisEnqueued = true
}
}
if mysqlEnqueued && redisEnqueued {
s.notifyMentions(ctx, comment)
return nil
}
// Fallback: direct MySQL write when comment MQ publish fails.
if !mysqlEnqueued {
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("video not found")
}
return err
}
if err := tx.Create(comment).Error; err != nil {
return err
}
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
}); err != nil {
return err
}
}
// Fallback: direct Redis update when popularity MQ publish fails.
if !redisEnqueued {
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
}
s.notifyMentions(ctx, comment)
return nil
}
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
comment, err := s.repo.GetByID(ctx, commentID)
if err != nil {
return err
}
if comment == nil {
return errors.New("comment not found")
}
if comment.AuthorID != accountID {
return apierror.ErrUnauthorized
}
if s.commentMQ != nil {
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
return nil
}
}
return s.repo.DeleteComment(ctx, comment)
}
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
exists, err := s.VideoRepository.IsExist(ctx, videoID)
if err != nil {
return nil, err
}
if !exists {
return nil, errors.New("video not found")
}
return s.repo.GetAllComments(ctx, videoID)
}
var mentionRegex = regexp.MustCompile(`@(\w+)`)
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
if len(matches) == 0 {
return
}
seen := make(map[string]bool)
for _, m := range matches {
username := m[1]
if seen[username] || username == comment.Username {
continue
}
seen[username] = true
var accID uint
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
continue
}
notif := struct {
RecipientID uint
SenderID uint
Type string
TargetID uint
Content string
}{
RecipientID: accID,
SenderID: comment.AuthorID,
Type: "mention",
TargetID: comment.VideoID,
Content: comment.Username + " 在评论中提到了你",
}
s.repo.db.WithContext(ctx).Table("notifications").Create(&notif)
}
}

View File

@@ -1,114 +1,114 @@
package video
import (
"feedsystem_video_go/internal/middleware/jwt"
"feedsystem_video_go/internal/apierror"
"github.com/gin-gonic/gin"
)
type LikeHandler struct {
service *LikeService
}
func NewLikeHandler(service *LikeService) *LikeHandler {
return &LikeHandler{service: service}
}
func (lh *LikeHandler) Like(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
like := &Like{
VideoID: req.VideoID,
AccountID: accountID,
}
if err := lh.service.Like(c.Request.Context(), like); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "like success"})
}
func (lh *LikeHandler) Unlike(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
like := &Like{
VideoID: req.VideoID,
AccountID: accountID,
}
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "unlike success"})
}
func (lh *LikeHandler) IsLiked(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"is_liked": isLiked})
}
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
if videos == nil {
videos = []Video{}
}
c.JSON(200, videos)
}
package video
import (
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
)
type LikeHandler struct {
service *LikeService
}
func NewLikeHandler(service *LikeService) *LikeHandler {
return &LikeHandler{service: service}
}
func (lh *LikeHandler) Like(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
like := &Like{
VideoID: req.VideoID,
AccountID: accountID,
}
if err := lh.service.Like(c.Request.Context(), like); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "like success"})
}
func (lh *LikeHandler) Unlike(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
like := &Like{
VideoID: req.VideoID,
AccountID: accountID,
}
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "unlike success"})
}
func (lh *LikeHandler) IsLiked(c *gin.Context) {
var req LikeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if req.VideoID <= 0 {
c.JSON(400, gin.H{"error": "video_id is required"})
return
}
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"is_liked": isLiked})
}
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
accountID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
if videos == nil {
videos = []Video{}
}
c.JSON(200, videos)
}

View File

@@ -1,102 +1,102 @@
package video
import (
"context"
"errors"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
type LikeRepository struct {
db *gorm.DB
}
func NewLikeRepository(db *gorm.DB) *LikeRepository {
return &LikeRepository{db: db}
}
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).Create(like).Error
}
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
Delete(&Like{}).Error
}
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
return false, nil
}
err = r.db.WithContext(ctx).Create(like).Error
if err == nil {
return true, nil
}
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return false, nil
}
return false, err
}
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
if videoID == 0 || accountID == 0 {
return false, nil
}
res := r.db.WithContext(ctx).
Where("video_id = ? AND account_id = ?", videoID, accountID).
Delete(&Like{})
return res.RowsAffected > 0, res.Error
}
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id = ? AND account_id = ?", videoID, accountID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
likeMap := make(map[uint]bool)
if len(videoIDs) == 0 {
return likeMap, nil
}
if accountID == 0 {
return likeMap, nil
}
var likes []Like
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
Find(&likes).Error
if err != nil {
return nil, err
}
for _, like := range likes {
likeMap[like.VideoID] = true
}
return likeMap, nil
}
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
var videos []Video
if accountID == 0 {
return videos, nil
}
err := r.db.WithContext(ctx).
Model(&Video{}).
Joins("JOIN likes ON likes.video_id = videos.id").
Where("likes.account_id = ?", accountID).
Order("likes.created_at desc").
Limit(200).
Find(&videos).Error
if err != nil {
return nil, err
}
return videos, nil
}
package video
import (
"context"
"errors"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
type LikeRepository struct {
db *gorm.DB
}
func NewLikeRepository(db *gorm.DB) *LikeRepository {
return &LikeRepository{db: db}
}
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).Create(like).Error
}
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
return r.db.WithContext(ctx).
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
Delete(&Like{}).Error
}
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
return false, nil
}
err = r.db.WithContext(ctx).Create(like).Error
if err == nil {
return true, nil
}
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return false, nil
}
return false, err
}
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
if videoID == 0 || accountID == 0 {
return false, nil
}
res := r.db.WithContext(ctx).
Where("video_id = ? AND account_id = ?", videoID, accountID).
Delete(&Like{})
return res.RowsAffected > 0, res.Error
}
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id = ? AND account_id = ?", videoID, accountID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
likeMap := make(map[uint]bool)
if len(videoIDs) == 0 {
return likeMap, nil
}
if accountID == 0 {
return likeMap, nil
}
var likes []Like
err := r.db.WithContext(ctx).Model(&Like{}).
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
Find(&likes).Error
if err != nil {
return nil, err
}
for _, like := range likes {
likeMap[like.VideoID] = true
}
return likeMap, nil
}
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
var videos []Video
if accountID == 0 {
return videos, nil
}
err := r.db.WithContext(ctx).
Model(&Video{}).
Joins("JOIN likes ON likes.video_id = videos.id").
Where("likes.account_id = ?", accountID).
Order("likes.created_at desc").
Limit(200).
Find(&videos).Error
if err != nil {
return nil, err
}
return videos, nil
}

View File

@@ -1,28 +1,28 @@
package video
import (
"context"
"strconv"
"time"
rediscache "feedsystem_video_go/internal/middleware/redis"
)
// 更新视频流行度缓存
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
if cache == nil || id == 0 || change == 0 {
return
}
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
now := time.Now().UTC().Truncate(time.Minute)
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
member := strconv.FormatUint(uint64(id), 10)
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
}
package video
import (
"context"
"strconv"
"time"
rediscache "feedsystem_video_go/internal/middleware/redis"
)
// 更新视频流行度缓存
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
if cache == nil || id == 0 || change == 0 {
return
}
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
now := time.Now().UTC().Truncate(time.Minute)
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
member := strconv.FormatUint(uint64(id), 10)
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
}

View File

@@ -1,48 +1,48 @@
package video
import "time"
type Video struct {
ID uint `gorm:"primaryKey" json:"id"`
AuthorID uint `gorm:"index;not null" json:"author_id"`
Username string `gorm:"type:varchar(255);not null" json:"username"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"`
LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"`
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
}
type PublishVideoRequest struct {
Title string `json:"title"`
Description string `json:"description"`
PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"`
}
type DeleteVideoRequest struct {
ID uint `json:"id"`
}
type ListByAuthorIDRequest struct {
AuthorID uint `json:"author_id"`
}
type GetDetailRequest struct {
ID uint `json:"id"`
}
type UpdateLikesCountRequest struct {
ID uint `json:"id"`
LikesCount int64 `json:"likes_count"`
}
type OutboxMsg struct {
ID uint `gorm:"primaryKey"`
VideoID uint `gorm:"index"`
EventType string `gorm:"type:varchar(50)"`
CreateTime time.Time `gorm:"autoCreateTime"`
Status string `gorm:"type:varchar(50);index"`
}
package video
import "time"
type Video struct {
ID uint `gorm:"primaryKey" json:"id"`
AuthorID uint `gorm:"index;not null" json:"author_id"`
Username string `gorm:"type:varchar(255);not null" json:"username"`
Title string `gorm:"type:varchar(255);not null" json:"title"`
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"`
LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"`
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
}
type PublishVideoRequest struct {
Title string `json:"title"`
Description string `json:"description"`
PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"`
}
type DeleteVideoRequest struct {
ID uint `json:"id"`
}
type ListByAuthorIDRequest struct {
AuthorID uint `json:"author_id"`
}
type GetDetailRequest struct {
ID uint `json:"id"`
}
type UpdateLikesCountRequest struct {
ID uint `json:"id"`
LikesCount int64 `json:"likes_count"`
}
type OutboxMsg struct {
ID uint `gorm:"primaryKey"`
VideoID uint `gorm:"index"`
EventType string `gorm:"type:varchar(50)"`
CreateTime time.Time `gorm:"autoCreateTime"`
Status string `gorm:"type:varchar(50);index"`
}

View File

@@ -1,254 +1,254 @@
package video
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
)
type VideoHandler struct {
service *VideoService
accountService *account.AccountService
}
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
return &VideoHandler{service: service, accountService: accountService}
}
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
var req PublishVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
username, err := jwt.GetUsername(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
video := &Video{
AuthorID: authorId,
Username: username,
Title: req.Title,
Description: req.Description,
PlayURL: req.PlayURL,
CoverURL: req.CoverURL,
CreateTime: time.Now(),
}
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 200 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
if ext != ".mp4" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
return
}
date := time.Now().Format("20060102")
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
root := filepath.Join(".run", "uploads")
absDir := filepath.Join(root, relDir)
if err := os.MkdirAll(absDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename = filename + ext
absPath := filepath.Join(absDir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
c.JSON(http.StatusOK, gin.H{
"url": buildAbsoluteURL(c, urlPath),
"play_url": buildAbsoluteURL(c, urlPath),
})
}
func (vh *VideoHandler) UploadCover(c *gin.Context) {
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 10 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
return
}
date := time.Now().Format("20060102")
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
root := filepath.Join(".run", "uploads")
absDir := filepath.Join(root, relDir)
if err := os.MkdirAll(absDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename = filename + ext
absPath := filepath.Join(absDir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
c.JSON(http.StatusOK, gin.H{
"url": buildAbsoluteURL(c, urlPath),
"cover_url": buildAbsoluteURL(c, urlPath),
})
}
func randHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(b), nil
}
func buildAbsoluteURL(c *gin.Context, p string) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
scheme = xf
}
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
}
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
var req DeleteVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "video deleted"})
}
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
var req ListByAuthorIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if videos == nil {
videos = []Video{}
}
c.JSON(200, videos)
}
func (vh *VideoHandler) GetDetail(c *gin.Context) {
var req GetDetailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
var req UpdateLikesCountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "likes count updated"})
}
package video
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"github.com/gin-gonic/gin"
)
type VideoHandler struct {
service *VideoService
accountService *account.AccountService
}
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
return &VideoHandler{service: service, accountService: accountService}
}
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
var req PublishVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
username, err := jwt.GetUsername(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
video := &Video{
AuthorID: authorId,
Username: username,
Title: req.Title,
Description: req.Description,
PlayURL: req.PlayURL,
CoverURL: req.CoverURL,
CreateTime: time.Now(),
}
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 200 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
if ext != ".mp4" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
return
}
date := time.Now().Format("20060102")
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
root := filepath.Join(".run", "uploads")
absDir := filepath.Join(root, relDir)
if err := os.MkdirAll(absDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename = filename + ext
absPath := filepath.Join(absDir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
c.JSON(http.StatusOK, gin.H{
"url": buildAbsoluteURL(c, urlPath),
"play_url": buildAbsoluteURL(c, urlPath),
})
}
func (vh *VideoHandler) UploadCover(c *gin.Context) {
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
const maxSize = 10 << 20
if f.Size <= 0 || f.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
return
}
date := time.Now().Format("20060102")
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
root := filepath.Join(".run", "uploads")
absDir := filepath.Join(root, relDir)
if err := os.MkdirAll(absDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename, err := randHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename = filename + ext
absPath := filepath.Join(absDir, filename)
if err := c.SaveUploadedFile(f, absPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
c.JSON(http.StatusOK, gin.H{
"url": buildAbsoluteURL(c, urlPath),
"cover_url": buildAbsoluteURL(c, urlPath),
})
}
func randHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(b), nil
}
func buildAbsoluteURL(c *gin.Context, p string) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
scheme = xf
}
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
}
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
var req DeleteVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
authorId, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "video deleted"})
}
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
var req ListByAuthorIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if videos == nil {
videos = []Video{}
}
c.JSON(200, videos)
}
func (vh *VideoHandler) GetDetail(c *gin.Context) {
var req GetDetailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, video)
}
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
var req UpdateLikesCountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "likes count updated"})
}

View File

@@ -1,120 +1,120 @@
package video
import (
"context"
"errors"
"gorm.io/gorm"
)
type VideoRepository struct {
db *gorm.DB
}
func NewVideoRepository(db *gorm.DB) *VideoRepository {
return &VideoRepository{db: db}
}
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
var videos []Video
if err := vr.db.WithContext(ctx).
Where("author_id = ?", authorID).
Order("create_time desc").
Limit(200).
Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
return (*Video)(nil), err
}
return &video, nil
}
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
Update("likes_count", likesCount).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) {
var count int64
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) {
var total int64
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
package video
import (
"context"
"errors"
"gorm.io/gorm"
)
type VideoRepository struct {
db *gorm.DB
}
func NewVideoRepository(db *gorm.DB) *VideoRepository {
return &VideoRepository{db: db}
}
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
var videos []Video
if err := vr.db.WithContext(ctx).
Where("author_id = ?", authorID).
Order("create_time desc").
Limit(200).
Find(&videos).Error; err != nil {
return nil, err
}
return videos, nil
}
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
return (*Video)(nil), err
}
return &video, nil
}
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
Update("likes_count", likesCount).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
var video Video
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
if err := vr.db.WithContext(ctx).Model(&Video{}).
Where("id = ?", id).
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
return err
}
return nil
}
func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) {
var count int64
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) {
var total int64
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}

View File

@@ -1,226 +1,226 @@
package video
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/apierror"
"gorm.io/gorm"
)
type VideoService struct {
repo *VideoRepository
cache *rediscache.Client
cacheTTL time.Duration
popularityMQ *rabbitmq.PopularityMQ
}
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
}
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
if video == nil {
return errors.New("video is nil")
}
video.Title = strings.TrimSpace(video.Title)
video.PlayURL = strings.TrimSpace(video.PlayURL)
video.CoverURL = strings.TrimSpace(video.CoverURL)
if video.Title == "" {
return errors.New("title is required")
}
if video.PlayURL == "" {
return errors.New("play url is required")
}
if video.CoverURL == "" {
return errors.New("cover url is required")
}
//事务保证视频写入库和消息写入本地消息表的一致性
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(video).Error; err != nil {
return err
}
msg := OutboxMsg{
VideoID: video.ID,
EventType: "video_published",
Status: "pending",
CreateTime: video.CreateTime,
}
if err := tx.Create(&msg).Error; err != nil {
return err
}
tags := ExtractTags(video.Title + " " + video.Description)
for _, tagName := range tags {
var tag Tag
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
}
return nil
})
return err
}
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return err
}
if video == nil {
return errors.New("video not found")
}
if video.AuthorID != authorID {
return apierror.ErrUnauthorized
}
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
return err
}
if vs.cache != nil {
cacheKey := vs.cache.Key("video:detail:id=%d", id)
_ = vs.cache.Del(context.Background(), cacheKey)
}
return nil
}
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
if err != nil {
return nil, err
}
return videos, nil
}
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
cacheKey := vs.cache.Key("video:detail:id=%d", id)
getCached := func() (*Video, bool) {
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := vs.cache.GetBytes(opCtx, cacheKey)
if err != nil {
return nil, false
}
var cached Video
if err := json.Unmarshal(b, &cached); err != nil {
return nil, false
}
return &cached, true
}
setCached := func(video *Video) {
b, err := json.Marshal(video)
if err != nil {
return
}
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
}
if vs.cache != nil {
if v, ok := getCached(); ok {
return v, nil
}
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
b, err := vs.cache.GetBytes(opCtx, cacheKey)
cancel()
if err == nil {
var cached Video
if err := json.Unmarshal(b, &cached); err == nil {
return &cached, nil
}
} else if rediscache.IsMiss(err) {
lockKey := "lock:" + cacheKey
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
lockCancel()
if lockErr == nil && locked {
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
if v, ok := getCached(); ok {
return v, nil
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
setCached(video)
return video, nil
}
// 没拿到锁:等待别人回填缓存
for i := 0; i < 5; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(20 * time.Millisecond):
}
if v, ok := getCached(); ok {
return v, nil
}
}
}
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if vs.cache != nil {
setCached(video)
}
return video, nil
}
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
return err
}
return nil
}
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
return err
}
if vs.popularityMQ != nil {
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
return nil
}
}
if vs.cache != nil {
// 1) 详情缓存:直接失效(最简单靠谱)
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
// 2) 热榜写到“时间窗ZSET”不要用 detail key
now := time.Now().UTC().Truncate(time.Minute)
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
member := strconv.FormatUint(uint64(id), 10)
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
}
return nil
}
package video
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"gorm.io/gorm"
)
type VideoService struct {
repo *VideoRepository
cache *rediscache.Client
cacheTTL time.Duration
popularityMQ *rabbitmq.PopularityMQ
}
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
}
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
if video == nil {
return errors.New("video is nil")
}
video.Title = strings.TrimSpace(video.Title)
video.PlayURL = strings.TrimSpace(video.PlayURL)
video.CoverURL = strings.TrimSpace(video.CoverURL)
if video.Title == "" {
return errors.New("title is required")
}
if video.PlayURL == "" {
return errors.New("play url is required")
}
if video.CoverURL == "" {
return errors.New("cover url is required")
}
//事务保证视频写入库和消息写入本地消息表的一致性
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(video).Error; err != nil {
return err
}
msg := OutboxMsg{
VideoID: video.ID,
EventType: "video_published",
Status: "pending",
CreateTime: video.CreateTime,
}
if err := tx.Create(&msg).Error; err != nil {
return err
}
tags := ExtractTags(video.Title + " " + video.Description)
for _, tagName := range tags {
var tag Tag
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
}
return nil
})
return err
}
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return err
}
if video == nil {
return errors.New("video not found")
}
if video.AuthorID != authorID {
return apierror.ErrUnauthorized
}
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
return err
}
if vs.cache != nil {
cacheKey := vs.cache.Key("video:detail:id=%d", id)
_ = vs.cache.Del(context.Background(), cacheKey)
}
return nil
}
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
if err != nil {
return nil, err
}
return videos, nil
}
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
cacheKey := vs.cache.Key("video:detail:id=%d", id)
getCached := func() (*Video, bool) {
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := vs.cache.GetBytes(opCtx, cacheKey)
if err != nil {
return nil, false
}
var cached Video
if err := json.Unmarshal(b, &cached); err != nil {
return nil, false
}
return &cached, true
}
setCached := func(video *Video) {
b, err := json.Marshal(video)
if err != nil {
return
}
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
}
if vs.cache != nil {
if v, ok := getCached(); ok {
return v, nil
}
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
b, err := vs.cache.GetBytes(opCtx, cacheKey)
cancel()
if err == nil {
var cached Video
if err := json.Unmarshal(b, &cached); err == nil {
return &cached, nil
}
} else if rediscache.IsMiss(err) {
lockKey := "lock:" + cacheKey
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
lockCancel()
if lockErr == nil && locked {
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
if v, ok := getCached(); ok {
return v, nil
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
setCached(video)
return video, nil
}
// 没拿到锁:等待别人回填缓存
for i := 0; i < 5; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(20 * time.Millisecond):
}
if v, ok := getCached(); ok {
return v, nil
}
}
}
}
video, err := vs.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if vs.cache != nil {
setCached(video)
}
return video, nil
}
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
return err
}
return nil
}
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
return err
}
if vs.popularityMQ != nil {
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
return nil
}
}
if vs.cache != nil {
// 1) 详情缓存:直接失效(最简单靠谱)
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
// 2) 热榜写到“时间窗ZSET”不要用 detail key
now := time.Now().UTC().Truncate(time.Minute)
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
member := strconv.FormatUint(uint64(id), 10)
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,64 +1,64 @@
services:
mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
TZ: "Asia/Shanghai"
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
TZ: "Asia/Shanghai"
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 5s
retries: 20
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: always
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"]
interval: 5s
timeout: 3s
retries: 20
rabbitmq:
image: rabbitmq:3-management
restart: always
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
build:
context: .
dockerfile: backend/Dockerfile
target: api
restart: always
interval: 5s
timeout: 3s
retries: 20
rabbitmq:
image: rabbitmq:3-management
restart: always
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
build:
context: .
dockerfile: backend/Dockerfile
target: api
restart: always
environment:
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
@@ -66,30 +66,30 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
RABBITMQ_USER: ${RABBITMQ_USER:-admin}
RABBITMQ_PASS: ${RABBITMQ_PASS:-password123}
ports:
- "8080:8080"
volumes:
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
- backend_uploads:/app/.run/uploads
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep api || exit 1"]
interval: 10s
timeout: 5s
retries: 3
worker:
build:
context: .
dockerfile: backend/Dockerfile
target: worker
restart: always
ports:
- "8080:8080"
volumes:
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
- backend_uploads:/app/.run/uploads
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep api || exit 1"]
interval: 10s
timeout: 5s
retries: 3
worker:
build:
context: .
dockerfile: backend/Dockerfile
target: worker
restart: always
environment:
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
@@ -97,39 +97,39 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
RABBITMQ_USER: ${RABBITMQ_USER:-admin}
RABBITMQ_PASS: ${RABBITMQ_PASS:-password123}
volumes:
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep worker || exit 1"]
interval: 15s
timeout: 5s
retries: 3
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
restart: always
ports:
- "5173:80"
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
mysql_data:
redis_data:
rabbitmq_data:
backend_uploads:
volumes:
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep worker || exit 1"]
interval: 15s
timeout: 5s
retries: 3
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
restart: always
ports:
- "5173:80"
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
mysql_data:
redis_data:
rabbitmq_data:
backend_uploads:

View File

@@ -1,3 +1,3 @@
<template>
<RouterView />
</template>
<template>
<RouterView />
</template>

View File

@@ -1,48 +1,48 @@
import { postForm, postJson } from './client'
import type { Account, MessageResponse, TokenResponse } from './types'
export function register(username: string, password: string) {
return postJson<MessageResponse>('/account/register', { username, password })
}
export function login(username: string, password: string) {
return postJson<TokenResponse>('/account/login', { username, password })
}
export function logout() {
return postJson<MessageResponse>('/account/logout', {}, { authRequired: true })
}
export function rename(newUsername: string) {
return postJson<TokenResponse>('/account/rename', { new_username: newUsername }, { authRequired: true })
}
export function changePassword(username: string, oldPassword: string, newPassword: string) {
return postJson<MessageResponse>('/account/changePassword', {
username,
old_password: oldPassword,
new_password: newPassword,
})
}
export function findById(id: number) {
return postJson<Account>('/account/findByID', { id })
}
export function findByUsername(username: string) {
return postJson<Account>('/account/findByUsername', { username })
}
export function uploadAvatar(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true })
}
export function updateProfile(data: { avatar_url?: string; bio?: string }) {
return postJson<MessageResponse>('/account/updateProfile', data, { authRequired: true })
}
export function refresh(refreshToken: string) {
return postJson<TokenResponse>('/account/refresh', { refresh_token: refreshToken })
}
import { postForm, postJson } from './client'
import type { Account, MessageResponse, TokenResponse } from './types'
export function register(username: string, password: string) {
return postJson<MessageResponse>('/account/register', { username, password })
}
export function login(username: string, password: string) {
return postJson<TokenResponse>('/account/login', { username, password })
}
export function logout() {
return postJson<MessageResponse>('/account/logout', {}, { authRequired: true })
}
export function rename(newUsername: string) {
return postJson<TokenResponse>('/account/rename', { new_username: newUsername }, { authRequired: true })
}
export function changePassword(username: string, oldPassword: string, newPassword: string) {
return postJson<MessageResponse>('/account/changePassword', {
username,
old_password: oldPassword,
new_password: newPassword,
})
}
export function findById(id: number) {
return postJson<Account>('/account/findByID', { id })
}
export function findByUsername(username: string) {
return postJson<Account>('/account/findByUsername', { username })
}
export function uploadAvatar(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true })
}
export function updateProfile(data: { avatar_url?: string; bio?: string }) {
return postJson<MessageResponse>('/account/updateProfile', data, { authRequired: true })
}
export function refresh(refreshToken: string) {
return postJson<TokenResponse>('/account/refresh', { refresh_token: refreshToken })
}

View File

@@ -1,16 +1,16 @@
import { postJson } from './client'
import { normalizeCommentList } from './normalize'
import type { Comment, MessageResponse } from './types'
export async function listAll(videoId: number) {
const comments = await postJson<Comment[] | null>('/comment/listAll', { video_id: videoId })
return normalizeCommentList(comments)
}
export function publish(videoId: number, content: string) {
return postJson<MessageResponse>('/comment/publish', { video_id: videoId, content }, { authRequired: true })
}
export function remove(commentId: number) {
return postJson<MessageResponse>('/comment/delete', { comment_id: commentId }, { authRequired: true })
}
import { postJson } from './client'
import { normalizeCommentList } from './normalize'
import type { Comment, MessageResponse } from './types'
export async function listAll(videoId: number) {
const comments = await postJson<Comment[] | null>('/comment/listAll', { video_id: videoId })
return normalizeCommentList(comments)
}
export function publish(videoId: number, content: string) {
return postJson<MessageResponse>('/comment/publish', { video_id: videoId, content }, { authRequired: true })
}
export function remove(commentId: number) {
return postJson<MessageResponse>('/comment/delete', { comment_id: commentId }, { authRequired: true })
}

View File

@@ -1,28 +1,28 @@
import { postJson } from './client'
import { normalizeFeedVideoList } from './normalize'
import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types'
export async function listLatest(input: { limit: number; latest_time: number }) {
const res = await postJson<ListLatestResponse>('/feed/listLatest', input)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) {
const body: Record<string, unknown> = { limit: input.limit }
if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') {
body.likes_count_before = input.likes_count_before ?? 0
body.id_before = input.id_before ?? 0
}
const res = await postJson<ListLikesCountResponse>('/feed/listLikesCount', body)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) {
const res = await postJson<ListByPopularityResponse>('/feed/listByPopularity', input)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listByFollowing(input: { limit: number; latest_time: number }) {
const res = await postJson<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
import { postJson } from './client'
import { normalizeFeedVideoList } from './normalize'
import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types'
export async function listLatest(input: { limit: number; latest_time: number }) {
const res = await postJson<ListLatestResponse>('/feed/listLatest', input)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) {
const body: Record<string, unknown> = { limit: input.limit }
if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') {
body.likes_count_before = input.likes_count_before ?? 0
body.id_before = input.id_before ?? 0
}
const res = await postJson<ListLikesCountResponse>('/feed/listLikesCount', body)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) {
const res = await postJson<ListByPopularityResponse>('/feed/listByPopularity', input)
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}
export async function listByFollowing(input: { limit: number; latest_time: number }) {
const res = await postJson<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}

View File

@@ -1,18 +1,18 @@
import { postJson } from './client'
import type { IsLikedResponse, MessageResponse, Video } from './types'
export function like(videoId: number) {
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
}
export function unlike(videoId: number) {
return postJson<MessageResponse>('/like/unlike', { video_id: videoId }, { authRequired: true })
}
export function isLiked(videoId: number) {
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
}
export function listMyLikedVideos() {
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
}
import { postJson } from './client'
import type { IsLikedResponse, MessageResponse, Video } from './types'
export function like(videoId: number) {
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
}
export function unlike(videoId: number) {
return postJson<MessageResponse>('/like/unlike', { video_id: videoId }, { authRequired: true })
}
export function isLiked(videoId: number) {
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
}
export function listMyLikedVideos() {
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
}

View File

@@ -1,57 +1,57 @@
import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types'
export function listOrEmpty<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
export function normalizeAccount(value: Account | null | undefined): Account {
return {
id: Number(value?.id ?? 0),
username: value?.username || '匿名用户',
}
}
function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor {
return {
id: Number(value?.id ?? 0),
username: value?.username || '匿名用户',
}
}
export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem {
return {
...value,
author: normalizeAuthor(value.author),
title: value.title || '未命名视频',
description: value.description || '',
play_url: value.play_url || '',
cover_url: value.cover_url || '',
create_time: Number(value.create_time ?? 0),
likes_count: Number(value.likes_count ?? 0),
is_liked: Boolean(value.is_liked),
}
}
export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] {
return listOrEmpty(value).map(normalizeFeedVideoItem)
}
export function normalizeVideoList(value: Video[] | null | undefined): Video[] {
return listOrEmpty(value).map((video) => ({
...video,
username: video.username || '匿名用户',
title: video.title || '未命名视频',
description: video.description || '',
play_url: video.play_url || '',
cover_url: video.cover_url || '',
likes_count: Number(video.likes_count ?? 0),
}))
}
export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] {
return listOrEmpty(value).map((comment) => ({
...comment,
username: comment.username || '匿名用户',
content: comment.content || '',
}))
}
import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types'
export function listOrEmpty<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
export function normalizeAccount(value: Account | null | undefined): Account {
return {
id: Number(value?.id ?? 0),
username: value?.username || '匿名用户',
}
}
function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor {
return {
id: Number(value?.id ?? 0),
username: value?.username || '匿名用户',
}
}
export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem {
return {
...value,
author: normalizeAuthor(value.author),
title: value.title || '未命名视频',
description: value.description || '',
play_url: value.play_url || '',
cover_url: value.cover_url || '',
create_time: Number(value.create_time ?? 0),
likes_count: Number(value.likes_count ?? 0),
is_liked: Boolean(value.is_liked),
}
}
export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] {
return listOrEmpty(value).map(normalizeFeedVideoItem)
}
export function normalizeVideoList(value: Video[] | null | undefined): Video[] {
return listOrEmpty(value).map((video) => ({
...video,
username: video.username || '匿名用户',
title: video.title || '未命名视频',
description: video.description || '',
play_url: video.play_url || '',
cover_url: video.cover_url || '',
likes_count: Number(video.likes_count ?? 0),
}))
}
export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] {
return listOrEmpty(value).map((comment) => ({
...comment,
username: comment.username || '匿名用户',
content: comment.content || '',
}))
}

View File

@@ -1,29 +1,29 @@
import { postJson } from './client'
import { listOrEmpty, normalizeAccount } from './normalize'
import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types'
export function follow(vloggerId: number) {
return postJson<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
}
export function unfollow(vloggerId: number) {
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
}
export async function getAllFollowers(vloggerId?: number) {
const res = await postJson<GetAllFollowersResponse>(
'/social/getAllFollowers',
vloggerId ? { vlogger_id: vloggerId } : {},
{ authRequired: true },
)
return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) }
}
export async function getAllVloggers(followerId?: number) {
const res = await postJson<GetAllVloggersResponse>(
'/social/getAllVloggers',
followerId ? { follower_id: followerId } : {},
{ authRequired: true },
)
return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) }
}
import { postJson } from './client'
import { listOrEmpty, normalizeAccount } from './normalize'
import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types'
export function follow(vloggerId: number) {
return postJson<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
}
export function unfollow(vloggerId: number) {
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
}
export async function getAllFollowers(vloggerId?: number) {
const res = await postJson<GetAllFollowersResponse>(
'/social/getAllFollowers',
vloggerId ? { vlogger_id: vloggerId } : {},
{ authRequired: true },
)
return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) }
}
export async function getAllVloggers(followerId?: number) {
const res = await postJson<GetAllVloggersResponse>(
'/social/getAllVloggers',
followerId ? { follower_id: followerId } : {},
{ authRequired: true },
)
return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) }
}

View File

@@ -14,89 +14,89 @@ export type ListMessagesResponse = {
}
export type TokenResponse = { token: string; refresh_token?: string; account_id?: number; username?: string }
export type Account = {
id: number
username: string
avatar_url?: string
bio?: string
}
export type Video = {
id: number
author_id: number
username: string
title: string
description?: string
play_url: string
cover_url: string
create_time: string
likes_count: number
}
export type Comment = {
id: number
username: string
video_id: number
author_id: number
content: string
created_at: string
}
export type FeedAuthor = {
id: number
username: string
}
export type FeedVideoItem = {
id: number
author: FeedAuthor
title: string
description?: string
play_url: string
cover_url: string
create_time: number
likes_count: number
is_liked: boolean
}
export type ListLatestResponse = {
video_list: FeedVideoItem[]
next_time: number
has_more: boolean
}
export type ListLikesCountResponse = {
video_list: FeedVideoItem[]
next_likes_count_before?: number
next_id_before?: number
has_more: boolean
}
export type ListByPopularityResponse = {
video_list: FeedVideoItem[]
as_of: number
next_offset: number
has_more: boolean
next_latest_popularity?: number
next_latest_before?: string
next_latest_id_before?: number
}
export type ListByFollowingResponse = {
video_list: FeedVideoItem[]
next_time: number
has_more: boolean
}
export type IsLikedResponse = {
is_liked: boolean
}
export type GetAllFollowersResponse = {
followers: Account[]
}
export type GetAllVloggersResponse = {
vloggers: Account[]
}
export type Account = {
id: number
username: string
avatar_url?: string
bio?: string
}
export type Video = {
id: number
author_id: number
username: string
title: string
description?: string
play_url: string
cover_url: string
create_time: string
likes_count: number
}
export type Comment = {
id: number
username: string
video_id: number
author_id: number
content: string
created_at: string
}
export type FeedAuthor = {
id: number
username: string
}
export type FeedVideoItem = {
id: number
author: FeedAuthor
title: string
description?: string
play_url: string
cover_url: string
create_time: number
likes_count: number
is_liked: boolean
}
export type ListLatestResponse = {
video_list: FeedVideoItem[]
next_time: number
has_more: boolean
}
export type ListLikesCountResponse = {
video_list: FeedVideoItem[]
next_likes_count_before?: number
next_id_before?: number
has_more: boolean
}
export type ListByPopularityResponse = {
video_list: FeedVideoItem[]
as_of: number
next_offset: number
has_more: boolean
next_latest_popularity?: number
next_latest_before?: string
next_latest_id_before?: number
}
export type ListByFollowingResponse = {
video_list: FeedVideoItem[]
next_time: number
has_more: boolean
}
export type IsLikedResponse = {
is_liked: boolean
}
export type GetAllFollowersResponse = {
followers: Account[]
}
export type GetAllVloggersResponse = {
vloggers: Account[]
}

View File

@@ -1,93 +1,93 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import Toaster from './Toaster.vue'
const props = defineProps<{ full?: boolean }>()
const auth = useAuthStore()
const social = useSocialStore()
const router = useRouter()
const route = useRoute()
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
watch(
() => route.query.q,
(v) => {
search.value = typeof v === 'string' ? v : ''
},
)
watch(
() => auth.isLoggedIn,
(v) => {
if (v) void social.refreshMine()
else social.clear()
},
{ immediate: true },
)
const userLabel = computed(() => {
if (!auth.isLoggedIn) return '未登录'
const username = auth.claims?.username ?? '(unknown)'
const accountId = auth.claims?.account_id
return accountId ? `${username} #${accountId}` : username
})
async function onSearch() {
const q = search.value.trim()
await router.push({ path: '/', query: q ? { q } : {} })
}
async function goLogin() {
await router.push('/account')
}
async function goSettings() {
await router.push('/settings')
}
</script>
<template>
<div class="dy-shell">
<aside class="dy-aside">
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
<nav class="dy-nav">
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import Toaster from './Toaster.vue'
const props = defineProps<{ full?: boolean }>()
const auth = useAuthStore()
const social = useSocialStore()
const router = useRouter()
const route = useRoute()
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
watch(
() => route.query.q,
(v) => {
search.value = typeof v === 'string' ? v : ''
},
)
watch(
() => auth.isLoggedIn,
(v) => {
if (v) void social.refreshMine()
else social.clear()
},
{ immediate: true },
)
const userLabel = computed(() => {
if (!auth.isLoggedIn) return '未登录'
const username = auth.claims?.username ?? '(unknown)'
const accountId = auth.claims?.account_id
return accountId ? `${username} #${accountId}` : username
})
async function onSearch() {
const q = search.value.trim()
await router.push({ path: '/', query: q ? { q } : {} })
}
async function goLogin() {
await router.push('/account')
}
async function goSettings() {
await router.push('/settings')
}
</script>
<template>
<div class="dy-shell">
<aside class="dy-aside">
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
<nav class="dy-nav">
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
<RouterLink class="dy-nav-link" to="/hot">热榜</RouterLink>
<RouterLink class="dy-nav-link" to="/video">发布</RouterLink>
<RouterLink class="dy-nav-link" to="/account">账号</RouterLink>
<RouterLink v-if="auth.isLoggedIn" class="dy-nav-link" to="/messages">私信</RouterLink>
<RouterLink class="dy-nav-link" to="/settings">设置</RouterLink>
</nav>
<div class="dy-aside-foot">
<div class="dy-user">
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
<span class="dy-user-name">{{ userLabel }}</span>
</div>
<div class="dy-user-actions">
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
</div>
</div>
</aside>
<div class="dy-main">
<header class="dy-topbar">
<div class="dy-top-left">
<div class="dy-tabs-hint">{{ route.name }}</div>
</div>
<div class="dy-search">
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
</div>
<div class="dy-aside-foot">
<div class="dy-user">
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
<span class="dy-user-name">{{ userLabel }}</span>
</div>
<div class="dy-user-actions">
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
</div>
</div>
</aside>
<div class="dy-main">
<header class="dy-topbar">
<div class="dy-top-left">
<div class="dy-tabs-hint">{{ route.name }}</div>
</div>
<div class="dy-search">
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
</div>
<div class="dy-top-right">
<RouterLink class="dy-btn dy-btn-ghost" to="/video">+ 发布视频</RouterLink>
</div>
@@ -100,24 +100,24 @@ async function goSettings() {
<RouterLink v-if="auth.isLoggedIn" class="dy-mobile-link" to="/messages">私信</RouterLink>
<RouterLink class="dy-mobile-link" to="/account">账号</RouterLink>
</nav>
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
<template v-if="props.full">
<slot />
</template>
<template v-else>
<div class="container">
<slot />
</div>
</template>
</div>
</div>
<Toaster />
</div>
</template>
<style scoped>
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
<template v-if="props.full">
<slot />
</template>
<template v-else>
<div class="container">
<slot />
</div>
</template>
</div>
</div>
<Toaster />
</div>
</template>
<style scoped>
.dy-shell {
height: 100vh;
display: grid;
@@ -132,10 +132,10 @@ async function goSettings() {
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 14px;
}
.dy-logo {
gap: 14px;
}
.dy-logo {
font-weight: 900;
letter-spacing: 0;
font-size: 18px;
@@ -145,12 +145,12 @@ async function goSettings() {
border: 1px solid rgba(43, 161, 255, 0.22);
text-decoration: none;
}
.dy-nav {
display: grid;
gap: 8px;
}
.dy-nav {
display: grid;
gap: 8px;
}
.dy-nav-link {
padding: 10px 10px;
border-radius: 8px;
@@ -165,34 +165,34 @@ async function goSettings() {
background: rgba(43, 161, 255, 0.14);
color: rgba(246, 248, 251, 0.96);
}
.dy-aside-foot {
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 12px;
.dy-aside-foot {
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.dy-user {
display: flex;
gap: 10px;
align-items: center;
}
.dy-user-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.dy-user-dot.ok {
background: rgba(34, 197, 94, 1);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
}
}
.dy-user {
display: flex;
gap: 10px;
align-items: center;
}
.dy-user-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.dy-user-dot.ok {
background: rgba(34, 197, 94, 1);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
}
.dy-user-dot.bad {
background: rgba(255, 93, 115, 1);
box-shadow: 0 0 0 3px rgba(255, 93, 115, 0.14);
@@ -201,36 +201,36 @@ async function goSettings() {
.dy-user-name {
font-size: 13px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dy-user-actions {
display: flex;
gap: 10px;
}
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dy-user-actions {
display: flex;
gap: 10px;
}
.dy-btn {
appearance: none;
appearance: none;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
font-size: 13px;
}
.dy-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
padding: 10px 12px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
font-size: 13px;
}
.dy-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.dy-btn-primary {
border-color: rgba(43, 161, 255, 0.5);
background: rgba(43, 161, 255, 0.16);
@@ -244,72 +244,72 @@ async function goSettings() {
border-color: var(--border);
background: rgba(255, 255, 255, 0.045);
}
.dy-main {
height: 100vh;
display: flex;
flex-direction: column;
min-width: 0;
}
.dy-main {
height: 100vh;
display: flex;
flex-direction: column;
min-width: 0;
}
.dy-topbar {
height: 56px;
border-bottom: 1px solid var(--border);
background: rgba(12, 16, 23, 0.68);
backdrop-filter: blur(18px);
display: grid;
grid-template-columns: 180px 1fr 180px;
gap: 12px;
align-items: center;
padding: 0 14px;
}
display: grid;
grid-template-columns: 180px 1fr 180px;
gap: 12px;
align-items: center;
padding: 0 14px;
}
.dy-tabs-hint {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0;
}
.dy-search {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
max-width: 680px;
width: 100%;
justify-self: center;
}
.dy-search {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
max-width: 680px;
width: 100%;
justify-self: center;
}
.dy-search-input {
width: 100%;
width: 100%;
background: rgba(255, 255, 255, 0.055);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 14px;
outline: none;
}
padding: 10px 14px;
outline: none;
}
.dy-search-input:focus {
border-color: rgba(43, 161, 255, 0.52);
box-shadow: 0 0 0 3px rgba(43, 161, 255, 0.14);
}
.dy-top-right {
display: flex;
justify-content: flex-end;
}
.dy-content {
flex: 1;
min-height: 0;
}
.dy-content.padded {
overflow: auto;
}
.dy-top-right {
display: flex;
justify-content: flex-end;
}
.dy-content {
flex: 1;
min-height: 0;
}
.dy-content.padded {
overflow: auto;
}
.dy-content.full {
overflow: hidden;
}
@@ -320,18 +320,18 @@ async function goSettings() {
@media (max-width: 900px) {
.dy-shell {
grid-template-columns: 1fr;
}
.dy-aside {
display: none;
}
grid-template-columns: 1fr;
}
.dy-aside {
display: none;
}
.dy-topbar {
grid-template-columns: 1fr;
padding: 0 10px;
}
.dy-top-left {
display: none;
}
.dy-top-left {
display: none;
}
.dy-top-right {
display: none;
}

View File

@@ -1,89 +1,89 @@
<script setup lang="ts">
import type { FeedVideoItem } from '../api/types'
const props = defineProps<{
item: FeedVideoItem
canLike: boolean
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-like', item: FeedVideoItem): void
}>()
function onToggle() {
emit('toggle-like', props.item)
}
</script>
<template>
<div class="feed-card">
<div class="cover">
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
</div>
<div class="content">
<div class="row" style="justify-content: space-between">
<div>
<div class="title">
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
</div>
<div class="subtle">
作者{{ item.author.username }} (#{{ item.author.id }}) · 创建时间{{ new Date(item.create_time * 1000).toLocaleString() }}
</div>
</div>
<div class="row">
<span class="pill mono"> {{ item.likes_count }}</span>
<button
v-if="canLike"
class="primary"
type="button"
:disabled="busy"
@click="onToggle"
:title="item.is_liked ? '取消点赞' : '点赞'"
>
{{ item.is_liked ? '已赞' : '点赞' }}
</button>
</div>
</div>
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
<div class="row" style="margin-top: 10px">
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.feed-card {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 16px;
overflow: hidden;
}
.cover {
background: rgba(0, 0, 0, 0.25);
aspect-ratio: 16/9;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.content {
padding: 12px 12px 14px;
}
@media (max-width: 900px) {
.feed-card {
grid-template-columns: 1fr;
}
}
</style>
<script setup lang="ts">
import type { FeedVideoItem } from '../api/types'
const props = defineProps<{
item: FeedVideoItem
canLike: boolean
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-like', item: FeedVideoItem): void
}>()
function onToggle() {
emit('toggle-like', props.item)
}
</script>
<template>
<div class="feed-card">
<div class="cover">
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
</div>
<div class="content">
<div class="row" style="justify-content: space-between">
<div>
<div class="title">
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
</div>
<div class="subtle">
作者{{ item.author.username }} (#{{ item.author.id }}) · 创建时间{{ new Date(item.create_time * 1000).toLocaleString() }}
</div>
</div>
<div class="row">
<span class="pill mono"> {{ item.likes_count }}</span>
<button
v-if="canLike"
class="primary"
type="button"
:disabled="busy"
@click="onToggle"
:title="item.is_liked ? '取消点赞' : '点赞'"
>
{{ item.is_liked ? '已赞' : '点赞' }}
</button>
</div>
</div>
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
<div class="row" style="margin-top: 10px">
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.feed-card {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 16px;
overflow: hidden;
}
.cover {
background: rgba(0, 0, 0, 0.25);
aspect-ratio: 16/9;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.content {
padding: 12px 12px 14px;
}
@media (max-width: 900px) {
.feed-card {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value: unknown }>()
const text = computed(() => {
try {
return JSON.stringify(props.value, null, 2)
} catch {
return String(props.value)
}
})
</script>
<template>
<pre class="pre mono">{{ text }}</pre>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value: unknown }>()
const text = computed(() => {
try {
return JSON.stringify(props.value, null, 2)
} catch {
return String(props.value)
}
})
</script>
<template>
<pre class="pre mono">{{ text }}</pre>
</template>

View File

@@ -1,73 +1,73 @@
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
const toast = useToastStore()
</script>
<template>
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
<div class="toast-msg">{{ t.message }}</div>
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
</div>
</div>
</template>
<style scoped>
.toast-wrap {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: grid;
gap: 10px;
z-index: 200;
width: min(520px, calc(100vw - 24px));
pointer-events: none;
}
.toast {
pointer-events: auto;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
border-radius: 14px;
padding: 10px 12px;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.toast.success {
border-color: rgba(34, 197, 94, 0.35);
}
.toast.error {
border-color: rgba(254, 44, 85, 0.45);
}
.toast.info {
border-color: rgba(37, 244, 238, 0.3);
}
.toast-msg {
font-size: 13px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.92);
}
.toast-x {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0;
}
</style>
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
const toast = useToastStore()
</script>
<template>
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
<div class="toast-msg">{{ t.message }}</div>
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
</div>
</div>
</template>
<style scoped>
.toast-wrap {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: grid;
gap: 10px;
z-index: 200;
width: min(520px, calc(100vw - 24px));
pointer-events: none;
}
.toast {
pointer-events: auto;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
border-radius: 14px;
padding: 10px 12px;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.toast.success {
border-color: rgba(34, 197, 94, 0.35);
}
.toast.error {
border-color: rgba(254, 44, 85, 0.45);
}
.toast.info {
border-color: rgba(37, 244, 238, 0.3);
}
.toast-msg {
font-size: 13px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.92);
}
.toast-x {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0;
}
</style>

View File

@@ -1,57 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
username: string
id?: number
size?: number
src?: string
}>()
function hashToHue(input: string) {
let h = 0
for (let i = 0; i < input.length; i += 1) {
h = (h * 31 + input.charCodeAt(i)) >>> 0
}
return h % 360
}
const initial = computed(() => {
const s = (props.username ?? '').trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
})
const sizePx = computed(() => `${props.size ?? 40}px`)
const bg = computed(() => {
const seed = typeof props.id === 'number' ? String(props.id) : props.username
const hue = hashToHue(seed || '0')
const h1 = hue
const h2 = (hue + 40) % 360
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
})
</script>
<template>
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
<div v-else class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
{{ initial }}
</div>
</template>
<style scoped>
.avatar {
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.92);
font-weight: 900;
letter-spacing: 0.2px;
user-select: none;
object-fit: cover;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
username: string
id?: number
size?: number
src?: string
}>()
function hashToHue(input: string) {
let h = 0
for (let i = 0; i < input.length; i += 1) {
h = (h * 31 + input.charCodeAt(i)) >>> 0
}
return h % 360
}
const initial = computed(() => {
const s = (props.username ?? '').trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
})
const sizePx = computed(() => `${props.size ?? 40}px`)
const bg = computed(() => {
const seed = typeof props.id === 'number' ? String(props.id) : props.username
const hue = hashToHue(seed || '0')
const h1 = hue
const h2 = (hue + 40) % 360
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
})
</script>
<template>
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
<div v-else class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
{{ initial }}
</div>
</template>
<style scoped>
.avatar {
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.92);
font-weight: 900;
letter-spacing: 0.2px;
user-select: none;
object-fit: cover;
}
</style>

View File

@@ -1,16 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { reportError } from './utils/error-reporter'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.config.errorHandler = (err, _instance, info) => {
reportError(err instanceof Error ? err : new Error(String(err)), { info })
}
app.mount('#app')
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { reportError } from './utils/error-reporter'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.config.errorHandler = (err, _instance, info) => {
reportError(err instanceof Error ? err : new Error(String(err)), { info })
}
app.mount('#app')

View File

@@ -1,42 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import HotView from '../views/HotView.vue'
import VideoView from '../views/VideoView.vue'
import VideoDetailView from '../views/VideoDetailView.vue'
import AccountView from '../views/AccountView.vue'
import ChangePasswordView from '../views/ChangePasswordView.vue'
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import HotView from '../views/HotView.vue'
import VideoView from '../views/VideoView.vue'
import VideoDetailView from '../views/VideoDetailView.vue'
import AccountView from '../views/AccountView.vue'
import ChangePasswordView from '../views/ChangePasswordView.vue'
import RegisterView from '../views/RegisterView.vue'
import SettingsView from '../views/SettingsView.vue'
import UserProfileView from '../views/UserProfileView.vue'
import MessageView from '../views/MessageView.vue'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/feed', redirect: '/' },
{ path: '/hot', name: 'hot', component: HotView },
{ path: '/video', name: 'video', component: VideoView, meta: { requiresAuth: true } },
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
{ path: '/account', name: 'account', component: AccountView },
{ path: '/account/register', name: 'account-register', component: RegisterView },
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/feed', redirect: '/' },
{ path: '/hot', name: 'hot', component: HotView },
{ path: '/video', name: 'video', component: VideoView, meta: { requiresAuth: true } },
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
{ path: '/account', name: 'account', component: AccountView },
{ path: '/account/register', name: 'account-register', component: RegisterView },
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { requiresAuth: true } },
{ path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true },
{ path: '/messages', name: 'message-list', component: MessageView, meta: { requiresAuth: true } },
{ path: '/messages/:peerId', name: 'messages', component: MessageView, meta: { requiresAuth: true } },
],
})
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
next({ path: '/account', query: { redirect: to.fullPath } })
return
}
next()
})
export default router
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
next({ path: '/account', query: { redirect: to.fullPath } })
return
}
next()
})
export default router

View File

@@ -1,48 +1,48 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'
function readStored(key: string): string | null {
try { return localStorage.getItem(key) } catch { return null }
}
function writeStored(key: string, value: string) {
localStorage.setItem(key, value)
}
function removeStored(key: string) {
localStorage.removeItem(key)
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(readStored(ACCESS_KEY))
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
const isLoggedIn = computed(() => !!token.value)
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
function setToken(newToken: string) {
token.value = newToken
writeStored(ACCESS_KEY, newToken)
}
function setTokens(access: string, refresh: string) {
token.value = access
refreshToken.value = refresh
writeStored(ACCESS_KEY, access)
writeStored(REFRESH_KEY, refresh)
}
function clearTokens() {
token.value = null
refreshToken.value = null
removeStored(ACCESS_KEY)
removeStored(REFRESH_KEY)
}
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
})
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'
function readStored(key: string): string | null {
try { return localStorage.getItem(key) } catch { return null }
}
function writeStored(key: string, value: string) {
localStorage.setItem(key, value)
}
function removeStored(key: string) {
localStorage.removeItem(key)
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(readStored(ACCESS_KEY))
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
const isLoggedIn = computed(() => !!token.value)
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
function setToken(newToken: string) {
token.value = newToken
writeStored(ACCESS_KEY, newToken)
}
function setTokens(access: string, refresh: string) {
token.value = access
refreshToken.value = refresh
writeStored(ACCESS_KEY, access)
writeStored(REFRESH_KEY, refresh)
}
function clearTokens() {
token.value = null
refreshToken.value = null
removeStored(ACCESS_KEY)
removeStored(REFRESH_KEY)
}
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
})

View File

@@ -1,109 +1,109 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '../api/client'
import type { Account } from '../api/types'
import * as socialApi from '../api/social'
import { useAuthStore } from './auth'
export const useSocialStore = defineStore('social', () => {
const auth = useAuthStore()
const followers = ref<Account[]>([])
const vloggers = ref<Account[]>([])
const followersLoading = ref(false)
const vloggersLoading = ref(false)
const followersError = ref('')
const vloggersError = ref('')
const followerCount = computed(() => followers.value.length)
const followingCount = computed(() => vloggers.value.length)
function clear() {
followers.value = []
vloggers.value = []
followersError.value = ''
vloggersError.value = ''
followersLoading.value = false
vloggersLoading.value = false
}
function isFollowing(accountId: number) {
return vloggers.value.some((a) => a.id === accountId)
}
async function refreshFollowers(vloggerId?: number) {
if (!auth.isLoggedIn) {
clear()
return
}
followersLoading.value = true
followersError.value = ''
try {
const res = await socialApi.getAllFollowers(vloggerId)
followers.value = res.followers
} catch (e) {
followersError.value = e instanceof ApiError ? e.message : String(e)
followers.value = []
} finally {
followersLoading.value = false
}
}
async function refreshVloggers(followerId?: number) {
if (!auth.isLoggedIn) {
clear()
return
}
vloggersLoading.value = true
vloggersError.value = ''
try {
const res = await socialApi.getAllVloggers(followerId)
vloggers.value = res.vloggers
} catch (e) {
vloggersError.value = e instanceof ApiError ? e.message : String(e)
vloggers.value = []
} finally {
vloggersLoading.value = false
}
}
async function refreshMine() {
await Promise.all([refreshFollowers(), refreshVloggers()])
}
async function follow(vloggerId: number) {
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
await socialApi.follow(vloggerId)
await refreshVloggers()
}
async function unfollow(vloggerId: number) {
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
await socialApi.unfollow(vloggerId)
await refreshVloggers()
}
return {
followers,
vloggers,
followerCount,
followingCount,
followersLoading,
vloggersLoading,
followersError,
vloggersError,
clear,
isFollowing,
refreshMine,
refreshFollowers,
refreshVloggers,
follow,
unfollow,
}
})
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '../api/client'
import type { Account } from '../api/types'
import * as socialApi from '../api/social'
import { useAuthStore } from './auth'
export const useSocialStore = defineStore('social', () => {
const auth = useAuthStore()
const followers = ref<Account[]>([])
const vloggers = ref<Account[]>([])
const followersLoading = ref(false)
const vloggersLoading = ref(false)
const followersError = ref('')
const vloggersError = ref('')
const followerCount = computed(() => followers.value.length)
const followingCount = computed(() => vloggers.value.length)
function clear() {
followers.value = []
vloggers.value = []
followersError.value = ''
vloggersError.value = ''
followersLoading.value = false
vloggersLoading.value = false
}
function isFollowing(accountId: number) {
return vloggers.value.some((a) => a.id === accountId)
}
async function refreshFollowers(vloggerId?: number) {
if (!auth.isLoggedIn) {
clear()
return
}
followersLoading.value = true
followersError.value = ''
try {
const res = await socialApi.getAllFollowers(vloggerId)
followers.value = res.followers
} catch (e) {
followersError.value = e instanceof ApiError ? e.message : String(e)
followers.value = []
} finally {
followersLoading.value = false
}
}
async function refreshVloggers(followerId?: number) {
if (!auth.isLoggedIn) {
clear()
return
}
vloggersLoading.value = true
vloggersError.value = ''
try {
const res = await socialApi.getAllVloggers(followerId)
vloggers.value = res.vloggers
} catch (e) {
vloggersError.value = e instanceof ApiError ? e.message : String(e)
vloggers.value = []
} finally {
vloggersLoading.value = false
}
}
async function refreshMine() {
await Promise.all([refreshFollowers(), refreshVloggers()])
}
async function follow(vloggerId: number) {
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
await socialApi.follow(vloggerId)
await refreshVloggers()
}
async function unfollow(vloggerId: number) {
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
await socialApi.unfollow(vloggerId)
await refreshVloggers()
}
return {
followers,
vloggers,
followerCount,
followingCount,
followersLoading,
vloggersLoading,
followersError,
vloggersError,
clear,
isFollowing,
refreshMine,
refreshFollowers,
refreshVloggers,
follow,
unfollow,
}
})

View File

@@ -1,40 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type ToastType = 'success' | 'error' | 'info'
export type Toast = {
id: number
type: ToastType
message: string
}
let nextId = 1
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
function remove(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
function push(type: ToastType, message: string, ttlMs = 2600) {
const id = nextId++
toasts.value.push({ id, type, message })
window.setTimeout(() => remove(id), ttlMs)
}
function success(message: string) {
push('success', message)
}
function error(message: string) {
push('error', message, 3600)
}
function info(message: string) {
push('info', message)
}
return { toasts, push, remove, success, error, info }
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type ToastType = 'success' | 'error' | 'info'
export type Toast = {
id: number
type: ToastType
message: string
}
let nextId = 1
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
function remove(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
function push(type: ToastType, message: string, ttlMs = 2600) {
const id = nextId++
toasts.value.push({ id, type, message })
window.setTimeout(() => remove(id), ttlMs)
}
function success(message: string) {
push('success', message)
}
function error(message: string) {
push('error', message, 3600)
}
function info(message: string) {
push('info', message)
}
return { toasts, push, remove, success, error, info }
})

View File

@@ -14,19 +14,19 @@
--shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
'Segoe UI Emoji';
line-height: 1.5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
line-height: 1.5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(155, 171, 195, 0.35) transparent;
@@ -58,12 +58,12 @@ body {
linear-gradient(180deg, #10141d 0%, var(--bg) 42%, #0b0e14 100%);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: #dceeff;
}
@@ -86,57 +86,57 @@ a:hover {
.card + .card {
margin-top: 16px;
}
.grid {
display: grid;
gap: 12px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 900px) {
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.muted {
color: var(--muted);
}
.grid {
display: grid;
gap: 12px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 900px) {
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.muted {
color: var(--muted);
}
.title {
font-size: 18px;
font-weight: 800;
margin: 0 0 6px;
letter-spacing: 0;
}
.subtle {
font-size: 13px;
color: var(--muted);
}
label {
font-size: 13px;
color: var(--muted);
display: inline-block;
margin-bottom: 6px;
}
.subtle {
font-size: 13px;
color: var(--muted);
}
label {
font-size: 13px;
color: var(--muted);
display: inline-block;
margin-bottom: 6px;
}
input,
textarea,
select {
@@ -149,12 +149,12 @@ select {
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
textarea {
min-height: 88px;
resize: vertical;
}
textarea {
min-height: 88px;
resize: vertical;
}
input:focus,
textarea:focus,
select:focus {
@@ -174,11 +174,11 @@ button {
transition: 120ms ease;
font: inherit;
}
button:hover {
background: rgba(255, 255, 255, 0.12);
}
button:hover {
background: rgba(255, 255, 255, 0.12);
}
button.primary {
border-color: rgba(43, 161, 255, 0.55);
background: rgba(43, 161, 255, 0.18);
@@ -187,53 +187,53 @@ button.primary {
button.primary:hover {
background: rgba(43, 161, 255, 0.26);
}
button.danger {
border-color: rgba(255, 77, 109, 0.65);
background: rgba(255, 77, 109, 0.18);
}
button.danger:hover {
background: rgba(255, 77, 109, 0.26);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: rgba(255, 77, 109, 0.65);
background: rgba(255, 77, 109, 0.18);
}
button.danger:hover {
background: rgba(255, 77, 109, 0.26);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
font-size: 13px;
}
.pill.ok {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.12);
}
.pill.bad {
border-color: rgba(255, 77, 109, 0.55);
background: rgba(255, 77, 109, 0.12);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
font-size: 13px;
}
.pill.ok {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.12);
}
.pill.bad {
border-color: rgba(255, 77, 109, 0.55);
background: rgba(255, 77, 109, 0.12);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.pre {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.45;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.45;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 12px;
margin: 0;

View File

@@ -1,35 +1,35 @@
export type JwtPayload = {
account_id?: number
username?: string
exp?: number
iat?: number
nbf?: number
[key: string]: unknown
}
function base64UrlToBase64(input: string) {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
const pad = base64.length % 4
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
}
function base64ToUtf8String(base64: string) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
export function decodeJwtPayload(token: string): JwtPayload | null {
const [, payload] = token.split('.')
if (!payload) return null
try {
const json = base64ToUtf8String(base64UrlToBase64(payload))
const parsed = JSON.parse(json)
if (!parsed || typeof parsed !== 'object') return null
return parsed as JwtPayload
} catch {
return null
}
}
export type JwtPayload = {
account_id?: number
username?: string
exp?: number
iat?: number
nbf?: number
[key: string]: unknown
}
function base64UrlToBase64(input: string) {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
const pad = base64.length % 4
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
}
function base64ToUtf8String(base64: string) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
export function decodeJwtPayload(token: string): JwtPayload | null {
const [, payload] = token.split('.')
if (!payload) return null
try {
const json = base64ToUtf8String(base64UrlToBase64(payload))
const parsed = JSON.parse(json)
if (!parsed || typeof parsed !== 'object') return null
return parsed as JwtPayload
} catch {
return null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +1,72 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const toast = useToastStore()
const busy = ref(false)
const form = reactive({ username: '', oldPassword: '', newPassword: '' })
async function submit() {
if (busy.value) return
const username = form.username.trim()
const oldPassword = form.oldPassword.trim()
const newPassword = form.newPassword.trim()
if (!username || !oldPassword || !newPassword) {
toast.error('请把信息填完整')
return
}
busy.value = true
try {
await accountApi.changePassword(username, oldPassword, newPassword)
toast.success('密码已修改,请重新登录')
await router.push('/account')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">修改密码</p>
<p class="subtle">不需要登录对应后端 `/account/changePassword`</p>
<div class="grid" style="margin-top: 12px">
<div>
<label>username</label>
<input v-model.trim="form.username" autocomplete="username" />
</div>
<div>
<label>old_password</label>
<input v-model.trim="form.oldPassword" type="password" autocomplete="current-password" />
</div>
<div>
<label>new_password</label>
<input v-model.trim="form.newPassword" type="password" autocomplete="new-password" />
</div>
<div class="row" style="justify-content: flex-end">
<button class="primary" type="button" :disabled="busy" @click="submit">提交</button>
</div>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">改密成功后后端会让旧 token 失效请在账号页重新登录</p>
</div>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const toast = useToastStore()
const busy = ref(false)
const form = reactive({ username: '', oldPassword: '', newPassword: '' })
async function submit() {
if (busy.value) return
const username = form.username.trim()
const oldPassword = form.oldPassword.trim()
const newPassword = form.newPassword.trim()
if (!username || !oldPassword || !newPassword) {
toast.error('请把信息填完整')
return
}
busy.value = true
try {
await accountApi.changePassword(username, oldPassword, newPassword)
toast.success('密码已修改,请重新登录')
await router.push('/account')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">修改密码</p>
<p class="subtle">不需要登录对应后端 `/account/changePassword`</p>
<div class="grid" style="margin-top: 12px">
<div>
<label>username</label>
<input v-model.trim="form.username" autocomplete="username" />
</div>
<div>
<label>old_password</label>
<input v-model.trim="form.oldPassword" type="password" autocomplete="current-password" />
</div>
<div>
<label>new_password</label>
<input v-model.trim="form.newPassword" type="password" autocomplete="new-password" />
</div>
<div class="row" style="justify-content: flex-end">
<button class="primary" type="button" :disabled="busy" @click="submit">提交</button>
</div>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">改密成功后后端会让旧 token 失效请在账号页重新登录</p>
</div>
</div>
</AppShell>
</template>

View File

@@ -1,262 +1,262 @@
<script setup lang="ts">
import { computed, onMounted, reactive, watch } from 'vue'
import AppShell from '../components/AppShell.vue'
import JsonBox from '../components/JsonBox.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { ApiError } from '../api/client'
import * as feedApi from '../api/feed'
import * as likeApi from '../api/like'
import type { FeedVideoItem } from '../api/types'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
type ListState = {
loading: boolean
error: string
items: FeedVideoItem[]
has_more: boolean
}
const latest = reactive<ListState & { limit: number; next_time: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_time: 0,
})
const likesCount = reactive<ListState & { limit: number; next_likes_count_before?: number; next_id_before?: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_likes_count_before: undefined,
next_id_before: undefined,
})
const following = reactive<ListState & { limit: number; next_time: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_time: 0,
})
const action = reactive<{ loading: boolean; error: string; payload: unknown; name: string }>({
loading: false,
error: '',
payload: null,
name: '',
})
const canLike = computed(() => auth.isLoggedIn)
async function runAction(name: string, fn: () => Promise<unknown>) {
action.name = name
action.loading = true
action.error = ''
action.payload = null
try {
action.payload = await fn()
} catch (e) {
action.error = e instanceof ApiError ? e.message : String(e)
action.payload = e instanceof ApiError ? e.payload : null
} finally {
action.loading = false
}
}
async function loadLatest(reset: boolean) {
latest.loading = true
latest.error = ''
try {
const latest_time = reset ? 0 : latest.next_time
const res = await feedApi.listLatest({ limit: latest.limit, latest_time })
latest.has_more = res.has_more
latest.next_time = res.next_time
latest.items = reset ? res.video_list : latest.items.concat(res.video_list)
} catch (e) {
latest.error = e instanceof ApiError ? e.message : String(e)
} finally {
latest.loading = false
}
}
async function loadLikesCount(reset: boolean) {
likesCount.loading = true
likesCount.error = ''
try {
const res = await feedApi.listLikesCount({
limit: likesCount.limit,
likes_count_before: reset ? undefined : likesCount.next_likes_count_before,
id_before: reset ? undefined : likesCount.next_id_before,
})
likesCount.has_more = res.has_more
likesCount.next_likes_count_before = res.next_likes_count_before
likesCount.next_id_before = res.next_id_before
likesCount.items = reset ? res.video_list : likesCount.items.concat(res.video_list)
} catch (e) {
likesCount.error = e instanceof ApiError ? e.message : String(e)
} finally {
likesCount.loading = false
}
}
async function loadFollowing(reset: boolean) {
following.loading = true
following.error = ''
try {
const latest_time = reset ? 0 : following.next_time
const res = await feedApi.listByFollowing({ limit: following.limit, latest_time })
following.has_more = res.has_more
following.next_time = res.next_time
following.items = reset ? res.video_list : following.items.concat(res.video_list)
} catch (e) {
following.error = e instanceof ApiError ? e.message : String(e)
} finally {
following.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) return
await runAction(item.is_liked ? '取消点赞' : '点赞', async () => {
if (item.is_liked) await likeApi.unlike(item.id)
else await likeApi.like(item.id)
item.is_liked = !item.is_liked
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
return { ok: true, is_liked: item.is_liked, likes_count: item.likes_count }
})
}
onMounted(async () => {
await loadLatest(true)
await loadLikesCount(true)
if (auth.isLoggedIn) {
await loadFollowing(true)
}
})
watch(
() => auth.isLoggedIn,
async (v) => {
if (v && following.items.length === 0) {
await loadFollowing(true)
}
},
)
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">Feed</p>
<p class="subtle">`/feed/listLatest` `/feed/listLikesCount` 支持匿名可选 JWT`/feed/listByFollowing` 需要 JWT</p>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">最新流listLatest</p>
<div class="subtle">limit{{ latest.limit }} · next_time{{ latest.next_time }} · has_more{{ latest.has_more }}</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="latest.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="latest.loading" @click="loadLatest(true)">刷新</button>
<button type="button" :disabled="latest.loading || !latest.has_more" @click="loadLatest(false)">加载更多</button>
</div>
</div>
<div v-if="latest.error" class="pill bad" style="margin-top: 10px">错误{{ latest.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in latest.items"
:key="`latest-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">点赞数流listLikesCount</p>
<div class="subtle">
limit{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }})
· has_more{{ likesCount.has_more }}
</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="likesCount.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="likesCount.loading" @click="loadLikesCount(true)">刷新</button>
<button type="button" :disabled="likesCount.loading || !likesCount.has_more" @click="loadLikesCount(false)">
加载更多
</button>
</div>
</div>
<div v-if="likesCount.error" class="pill bad" style="margin-top: 10px">错误{{ likesCount.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in likesCount.items"
:key="`likes-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">关注流listByFollowingJWT</p>
<div class="subtle">
limit{{ following.limit }} · next_time{{ following.next_time }} · has_more{{ following.has_more }}
</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="following.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="following.loading" @click="loadFollowing(true)">刷新</button>
<button type="button" :disabled="following.loading || !following.has_more" @click="loadFollowing(false)">加载更多</button>
</div>
</div>
<div v-if="!auth.isLoggedIn" class="pill bad" style="margin-top: 10px">未登录无法访问关注流</div>
<div v-if="following.error" class="pill bad" style="margin-top: 10px">错误{{ following.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in following.items"
:key="`following-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
<div class="card">
<p class="title">动作输出点赞等</p>
<div class="row" style="margin-bottom: 10px">
<span class="pill">动作{{ action.name || '-' }}</span>
<span v-if="action.loading" class="pill">请求中</span>
<span v-if="action.error" class="pill bad">错误{{ action.error }}</span>
</div>
<JsonBox :value="action.payload" />
</div>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, watch } from 'vue'
import AppShell from '../components/AppShell.vue'
import JsonBox from '../components/JsonBox.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { ApiError } from '../api/client'
import * as feedApi from '../api/feed'
import * as likeApi from '../api/like'
import type { FeedVideoItem } from '../api/types'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
type ListState = {
loading: boolean
error: string
items: FeedVideoItem[]
has_more: boolean
}
const latest = reactive<ListState & { limit: number; next_time: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_time: 0,
})
const likesCount = reactive<ListState & { limit: number; next_likes_count_before?: number; next_id_before?: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_likes_count_before: undefined,
next_id_before: undefined,
})
const following = reactive<ListState & { limit: number; next_time: number }>({
loading: false,
error: '',
items: [],
has_more: false,
limit: 10,
next_time: 0,
})
const action = reactive<{ loading: boolean; error: string; payload: unknown; name: string }>({
loading: false,
error: '',
payload: null,
name: '',
})
const canLike = computed(() => auth.isLoggedIn)
async function runAction(name: string, fn: () => Promise<unknown>) {
action.name = name
action.loading = true
action.error = ''
action.payload = null
try {
action.payload = await fn()
} catch (e) {
action.error = e instanceof ApiError ? e.message : String(e)
action.payload = e instanceof ApiError ? e.payload : null
} finally {
action.loading = false
}
}
async function loadLatest(reset: boolean) {
latest.loading = true
latest.error = ''
try {
const latest_time = reset ? 0 : latest.next_time
const res = await feedApi.listLatest({ limit: latest.limit, latest_time })
latest.has_more = res.has_more
latest.next_time = res.next_time
latest.items = reset ? res.video_list : latest.items.concat(res.video_list)
} catch (e) {
latest.error = e instanceof ApiError ? e.message : String(e)
} finally {
latest.loading = false
}
}
async function loadLikesCount(reset: boolean) {
likesCount.loading = true
likesCount.error = ''
try {
const res = await feedApi.listLikesCount({
limit: likesCount.limit,
likes_count_before: reset ? undefined : likesCount.next_likes_count_before,
id_before: reset ? undefined : likesCount.next_id_before,
})
likesCount.has_more = res.has_more
likesCount.next_likes_count_before = res.next_likes_count_before
likesCount.next_id_before = res.next_id_before
likesCount.items = reset ? res.video_list : likesCount.items.concat(res.video_list)
} catch (e) {
likesCount.error = e instanceof ApiError ? e.message : String(e)
} finally {
likesCount.loading = false
}
}
async function loadFollowing(reset: boolean) {
following.loading = true
following.error = ''
try {
const latest_time = reset ? 0 : following.next_time
const res = await feedApi.listByFollowing({ limit: following.limit, latest_time })
following.has_more = res.has_more
following.next_time = res.next_time
following.items = reset ? res.video_list : following.items.concat(res.video_list)
} catch (e) {
following.error = e instanceof ApiError ? e.message : String(e)
} finally {
following.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) return
await runAction(item.is_liked ? '取消点赞' : '点赞', async () => {
if (item.is_liked) await likeApi.unlike(item.id)
else await likeApi.like(item.id)
item.is_liked = !item.is_liked
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
return { ok: true, is_liked: item.is_liked, likes_count: item.likes_count }
})
}
onMounted(async () => {
await loadLatest(true)
await loadLikesCount(true)
if (auth.isLoggedIn) {
await loadFollowing(true)
}
})
watch(
() => auth.isLoggedIn,
async (v) => {
if (v && following.items.length === 0) {
await loadFollowing(true)
}
},
)
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">Feed</p>
<p class="subtle">`/feed/listLatest` `/feed/listLikesCount` 支持匿名可选 JWT`/feed/listByFollowing` 需要 JWT</p>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">最新流listLatest</p>
<div class="subtle">limit{{ latest.limit }} · next_time{{ latest.next_time }} · has_more{{ latest.has_more }}</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="latest.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="latest.loading" @click="loadLatest(true)">刷新</button>
<button type="button" :disabled="latest.loading || !latest.has_more" @click="loadLatest(false)">加载更多</button>
</div>
</div>
<div v-if="latest.error" class="pill bad" style="margin-top: 10px">错误{{ latest.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in latest.items"
:key="`latest-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">点赞数流listLikesCount</p>
<div class="subtle">
limit{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }})
· has_more{{ likesCount.has_more }}
</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="likesCount.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="likesCount.loading" @click="loadLikesCount(true)">刷新</button>
<button type="button" :disabled="likesCount.loading || !likesCount.has_more" @click="loadLikesCount(false)">
加载更多
</button>
</div>
</div>
<div v-if="likesCount.error" class="pill bad" style="margin-top: 10px">错误{{ likesCount.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in likesCount.items"
:key="`likes-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
<div class="card" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<div>
<p class="title">关注流listByFollowingJWT</p>
<div class="subtle">
limit{{ following.limit }} · next_time{{ following.next_time }} · has_more{{ following.has_more }}
</div>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="following.limit" type="number" min="1" max="50" style="width: 90px" />
<button class="primary" type="button" :disabled="following.loading" @click="loadFollowing(true)">刷新</button>
<button type="button" :disabled="following.loading || !following.has_more" @click="loadFollowing(false)">加载更多</button>
</div>
</div>
<div v-if="!auth.isLoggedIn" class="pill bad" style="margin-top: 10px">未登录无法访问关注流</div>
<div v-if="following.error" class="pill bad" style="margin-top: 10px">错误{{ following.error }}</div>
<div class="grid" style="gap: 10px; margin-top: 12px">
<FeedVideoCard
v-for="item in following.items"
:key="`following-${item.id}`"
:item="item"
:can-like="canLike"
:busy="action.loading"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
<div class="card">
<p class="title">动作输出点赞等</p>
<div class="row" style="margin-bottom: 10px">
<span class="pill">动作{{ action.name || '-' }}</span>
<span v-if="action.loading" class="pill">请求中</span>
<span v-if="action.error" class="pill bad">错误{{ action.error }}</span>
</div>
<JsonBox :value="action.payload" />
</div>
</div>
</AppShell>
</template>

View File

@@ -1,157 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { ApiError } from '../api/client'
import * as feedApi from '../api/feed'
import * as likeApi from '../api/like'
import type { FeedVideoItem } from '../api/types'
import AppShell from '../components/AppShell.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const auth = useAuthStore()
const toast = useToastStore()
const canLike = computed(() => auth.isLoggedIn)
const state = reactive({
loading: false,
error: '',
items: [] as FeedVideoItem[],
hasMore: false,
limit: 10,
asOf: 0,
nextOffset: 0,
})
const likeBusy = reactive<Record<string, boolean>>({})
async function loadHot(reset: boolean) {
if (state.loading) return
state.loading = true
state.error = ''
try {
const res = await feedApi.listByPopularity({
limit: state.limit,
as_of: reset ? 0 : state.asOf,
offset: reset ? 0 : state.nextOffset,
})
state.hasMore = res.has_more
state.asOf = res.as_of
state.nextOffset = res.next_offset
state.items = reset ? res.video_list : state.items.concat(res.video_list)
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
} finally {
state.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) {
toast.error('请先登录')
return
}
const key = String(item.id)
if (likeBusy[key]) return
likeBusy[key] = true
try {
if (item.is_liked) await likeApi.unlike(item.id)
else await likeApi.like(item.id)
item.is_liked = !item.is_liked
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
likeBusy[key] = false
}
}
onMounted(async () => {
await loadHot(true)
})
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<div>
<p class="title" style="margin: 0">热榜</p>
<p class="subtle" style="margin: 6px 0 0">按热度排序/feed/listByPopularity</p>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
</div>
</div>
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误{{ state.error }}</div>
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中</div>
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
<FeedVideoCard
:item="item"
:can-like="canLike"
:busy="!!likeBusy[String(item.id)]"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.rank-list {
display: grid;
gap: 12px;
}
.rank-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.rank-num {
height: 44px;
width: 44px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 900;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
user-select: none;
}
.rank-num.top {
border-color: rgba(254, 44, 85, 0.55);
background: rgba(254, 44, 85, 0.18);
color: rgba(255, 255, 255, 0.96);
}
@media (max-width: 900px) {
.rank-row {
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
}
.rank-num {
height: 38px;
width: 38px;
border-radius: 14px;
}
}
</style>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { ApiError } from '../api/client'
import * as feedApi from '../api/feed'
import * as likeApi from '../api/like'
import type { FeedVideoItem } from '../api/types'
import AppShell from '../components/AppShell.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const auth = useAuthStore()
const toast = useToastStore()
const canLike = computed(() => auth.isLoggedIn)
const state = reactive({
loading: false,
error: '',
items: [] as FeedVideoItem[],
hasMore: false,
limit: 10,
asOf: 0,
nextOffset: 0,
})
const likeBusy = reactive<Record<string, boolean>>({})
async function loadHot(reset: boolean) {
if (state.loading) return
state.loading = true
state.error = ''
try {
const res = await feedApi.listByPopularity({
limit: state.limit,
as_of: reset ? 0 : state.asOf,
offset: reset ? 0 : state.nextOffset,
})
state.hasMore = res.has_more
state.asOf = res.as_of
state.nextOffset = res.next_offset
state.items = reset ? res.video_list : state.items.concat(res.video_list)
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
} finally {
state.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) {
toast.error('请先登录')
return
}
const key = String(item.id)
if (likeBusy[key]) return
likeBusy[key] = true
try {
if (item.is_liked) await likeApi.unlike(item.id)
else await likeApi.like(item.id)
item.is_liked = !item.is_liked
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
likeBusy[key] = false
}
}
onMounted(async () => {
await loadHot(true)
})
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<div>
<p class="title" style="margin: 0">热榜</p>
<p class="subtle" style="margin: 6px 0 0">按热度排序/feed/listByPopularity</p>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
</div>
</div>
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误{{ state.error }}</div>
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中</div>
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
<FeedVideoCard
:item="item"
:can-like="canLike"
:busy="!!likeBusy[String(item.id)]"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.rank-list {
display: grid;
gap: 12px;
}
.rank-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.rank-num {
height: 44px;
width: 44px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 900;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
user-select: none;
}
.rank-num.top {
border-color: rgba(254, 44, 85, 0.55);
background: rgba(254, 44, 85, 0.18);
color: rgba(255, 255, 255, 0.96);
}
@media (max-width: 900px) {
.rank-row {
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
}
.rank-num {
height: 38px;
width: 38px;
border-radius: 14px;
}
}
</style>

View File

@@ -1,67 +1,67 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const toast = useToastStore()
const busy = ref(false)
const form = reactive({ username: '', password: '' })
async function submit() {
if (busy.value) return
const username = form.username.trim()
const password = form.password.trim()
if (!username || !password) {
toast.error('请输入 username 和 password')
return
}
busy.value = true
try {
await accountApi.register(username, password)
toast.success('注册成功,请登录')
await router.push('/account')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">注册</p>
<p class="subtle">创建新账号对应后端 `/account/register`</p>
<div class="grid" style="margin-top: 12px">
<div>
<label>username</label>
<input v-model.trim="form.username" autocomplete="username" />
</div>
<div>
<label>password</label>
<input v-model.trim="form.password" type="password" autocomplete="new-password" />
</div>
<div class="row" style="justify-content: flex-end">
<button class="primary" type="button" :disabled="busy" @click="submit">注册</button>
</div>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">注册成功后会跳回账号页进行登录</p>
</div>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const toast = useToastStore()
const busy = ref(false)
const form = reactive({ username: '', password: '' })
async function submit() {
if (busy.value) return
const username = form.username.trim()
const password = form.password.trim()
if (!username || !password) {
toast.error('请输入 username 和 password')
return
}
busy.value = true
try {
await accountApi.register(username, password)
toast.success('注册成功,请登录')
await router.push('/account')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
</script>
<template>
<AppShell>
<div class="grid two">
<div class="card">
<p class="title">注册</p>
<p class="subtle">创建新账号对应后端 `/account/register`</p>
<div class="grid" style="margin-top: 12px">
<div>
<label>username</label>
<input v-model.trim="form.username" autocomplete="username" />
</div>
<div>
<label>password</label>
<input v-model.trim="form.password" type="password" autocomplete="new-password" />
</div>
<div class="row" style="justify-content: flex-end">
<button class="primary" type="button" :disabled="busy" @click="submit">注册</button>
</div>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">注册成功后会跳回账号页进行登录</p>
</div>
</div>
</AppShell>
</template>

View File

@@ -1,168 +1,168 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import UserAvatar from '../components/UserAvatar.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const me = computed(() => ({
id: auth.claims?.account_id ?? 0,
username: auth.claims?.username ?? '',
}))
const rename = reactive({
open: false,
newUsername: '',
})
async function openRename() {
if (!auth.isLoggedIn) return
rename.open = true
rename.newUsername = me.value.username
await nextTick()
}
async function submitRename() {
if (!auth.isLoggedIn) return
if (busy.value) return
const newUsername = rename.newUsername.trim()
if (!newUsername) {
toast.error('请输入新用户名')
return
}
busy.value = true
try {
const res = await accountApi.rename(newUsername)
auth.setToken(res.token)
rename.open = false
toast.success('改名成功(已刷新 token')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
async function goLogin() {
await router.push('/account')
}
async function goChangePassword() {
await router.push('/account/change-password')
}
async function onLogout() {
if (!auth.isLoggedIn) return
if (busy.value) return
if (!window.confirm('确认退出登录?')) return
busy.value = true
try {
await accountApi.logout()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(`登出失败:${msg}`)
} finally {
auth.clearTokens()
rename.open = false
toast.info('已退出登录')
busy.value = false
await router.push('/')
}
}
</script>
<template>
<AppShell>
<div v-if="!auth.isLoggedIn" class="grid two">
<div class="card">
<p class="title">设置</p>
<p class="subtle">需要先登录后才能进行改名/退出等操作</p>
<div class="row" style="margin-top: 12px; justify-content: flex-end">
<button class="primary" type="button" @click="goLogin">去登录</button>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">登录入口在账号</p>
</div>
</div>
<div v-else class="grid two">
<div class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start">
<div class="row" style="gap: 12px; align-items: center">
<UserAvatar :username="me.username" :id="me.id" :size="56" />
<div>
<div class="title" style="margin: 0">@{{ me.username }}</div>
<div class="subtle mono">#{{ me.id }}</div>
</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<div class="row" style="justify-content: space-between; align-items: center">
<p class="title" style="margin: 0">账号设置</p>
<button class="ghost" type="button" :disabled="busy" @click="openRename">改名</button>
</div>
<div v-if="rename.open" class="grid" style="margin-top: 12px">
<div>
<label>new_username</label>
<input v-model.trim="rename.newUsername" @keydown.enter="submitRename" />
</div>
<div class="row" style="justify-content: flex-end">
<button type="button" :disabled="busy" @click="rename.open = false">取消</button>
<button class="primary" type="button" :disabled="busy" @click="submitRename">提交</button>
</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<p class="title">账号安全</p>
<div class="row">
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
<button class="danger" type="button" :disabled="busy" @click="onLogout">退出登录</button>
</div>
</div>
</div>
<div class="card">
<p class="title">说明</p>
<div class="grid" style="margin-top: 10px">
<div class="pill ok">改名后会返回新 token token 立即失效</div>
<div class="pill ok">退出登录会清空本地 token</div>
<div class="pill">修改密码无需登录但成功后会让旧 token 失效</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.ghost {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import UserAvatar from '../components/UserAvatar.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const me = computed(() => ({
id: auth.claims?.account_id ?? 0,
username: auth.claims?.username ?? '',
}))
const rename = reactive({
open: false,
newUsername: '',
})
async function openRename() {
if (!auth.isLoggedIn) return
rename.open = true
rename.newUsername = me.value.username
await nextTick()
}
async function submitRename() {
if (!auth.isLoggedIn) return
if (busy.value) return
const newUsername = rename.newUsername.trim()
if (!newUsername) {
toast.error('请输入新用户名')
return
}
busy.value = true
try {
const res = await accountApi.rename(newUsername)
auth.setToken(res.token)
rename.open = false
toast.success('改名成功(已刷新 token')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
async function goLogin() {
await router.push('/account')
}
async function goChangePassword() {
await router.push('/account/change-password')
}
async function onLogout() {
if (!auth.isLoggedIn) return
if (busy.value) return
if (!window.confirm('确认退出登录?')) return
busy.value = true
try {
await accountApi.logout()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(`登出失败:${msg}`)
} finally {
auth.clearTokens()
rename.open = false
toast.info('已退出登录')
busy.value = false
await router.push('/')
}
}
</script>
<template>
<AppShell>
<div v-if="!auth.isLoggedIn" class="grid two">
<div class="card">
<p class="title">设置</p>
<p class="subtle">需要先登录后才能进行改名/退出等操作</p>
<div class="row" style="margin-top: 12px; justify-content: flex-end">
<button class="primary" type="button" @click="goLogin">去登录</button>
</div>
</div>
<div class="card">
<p class="title">提示</p>
<p class="muted">登录入口在账号</p>
</div>
</div>
<div v-else class="grid two">
<div class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start">
<div class="row" style="gap: 12px; align-items: center">
<UserAvatar :username="me.username" :id="me.id" :size="56" />
<div>
<div class="title" style="margin: 0">@{{ me.username }}</div>
<div class="subtle mono">#{{ me.id }}</div>
</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<div class="row" style="justify-content: space-between; align-items: center">
<p class="title" style="margin: 0">账号设置</p>
<button class="ghost" type="button" :disabled="busy" @click="openRename">改名</button>
</div>
<div v-if="rename.open" class="grid" style="margin-top: 12px">
<div>
<label>new_username</label>
<input v-model.trim="rename.newUsername" @keydown.enter="submitRename" />
</div>
<div class="row" style="justify-content: flex-end">
<button type="button" :disabled="busy" @click="rename.open = false">取消</button>
<button class="primary" type="button" :disabled="busy" @click="submitRename">提交</button>
</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<p class="title">账号安全</p>
<div class="row">
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
<button class="danger" type="button" :disabled="busy" @click="onLogout">退出登录</button>
</div>
</div>
</div>
<div class="card">
<p class="title">说明</p>
<div class="grid" style="margin-top: 10px">
<div class="pill ok">改名后会返回新 token token 立即失效</div>
<div class="pill ok">退出登录会清空本地 token</div>
<div class="pill">修改密码无需登录但成功后会让旧 token 失效</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.ghost {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.86);
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -1,107 +1,107 @@
<script setup lang="ts">
import { computed, onMounted, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import UserAvatar from '../components/UserAvatar.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import * as socialApi from '../api/social'
import type { Account, Video } from '../api/types'
import * as videoApi from '../api/video'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import { useToastStore } from '../stores/toast'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const social = useSocialStore()
const toast = useToastStore()
const userId = computed(() => Number(route.params.id))
const myId = computed(() => auth.claims?.account_id ?? 0)
const isMe = computed(() => myId.value > 0 && myId.value === userId.value)
const state = reactive({
loading: false,
error: '',
user: null as Account | null,
videos: [] as Video[],
followers: [] as Account[],
vloggers: [] as Account[],
socialLoading: false,
socialError: '',
})
const isFollowing = computed(() => (auth.isLoggedIn ? social.isFollowing(userId.value) : false))
async function loadProfile() {
if (!Number.isFinite(userId.value) || userId.value <= 0) {
state.error = '无效的用户 id'
return
}
state.loading = true
state.error = ''
try {
const [u, vids] = await Promise.all([accountApi.findById(userId.value), videoApi.listByAuthorId(userId.value)])
state.user = u
state.videos = vids
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
state.user = null
state.videos = []
} finally {
state.loading = false
}
await loadSocialCounts()
}
async function loadSocialCounts() {
state.socialError = ''
state.followers = []
state.vloggers = []
if (!auth.isLoggedIn) return
if (!Number.isFinite(userId.value) || userId.value <= 0) return
state.socialLoading = true
try {
const [followersRes, vloggersRes] = await Promise.all([
socialApi.getAllFollowers(userId.value),
socialApi.getAllVloggers(userId.value),
])
state.followers = followersRes.followers
state.vloggers = vloggersRes.vloggers
} catch (e) {
state.socialError = e instanceof ApiError ? e.message : String(e)
} finally {
state.socialLoading = false
}
}
<script setup lang="ts">
import { computed, onMounted, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import UserAvatar from '../components/UserAvatar.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import * as socialApi from '../api/social'
import type { Account, Video } from '../api/types'
import * as videoApi from '../api/video'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import { useToastStore } from '../stores/toast'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const social = useSocialStore()
const toast = useToastStore()
const userId = computed(() => Number(route.params.id))
const myId = computed(() => auth.claims?.account_id ?? 0)
const isMe = computed(() => myId.value > 0 && myId.value === userId.value)
const state = reactive({
loading: false,
error: '',
user: null as Account | null,
videos: [] as Video[],
followers: [] as Account[],
vloggers: [] as Account[],
socialLoading: false,
socialError: '',
})
const isFollowing = computed(() => (auth.isLoggedIn ? social.isFollowing(userId.value) : false))
async function loadProfile() {
if (!Number.isFinite(userId.value) || userId.value <= 0) {
state.error = '无效的用户 id'
return
}
state.loading = true
state.error = ''
try {
const [u, vids] = await Promise.all([accountApi.findById(userId.value), videoApi.listByAuthorId(userId.value)])
state.user = u
state.videos = vids
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
state.user = null
state.videos = []
} finally {
state.loading = false
}
await loadSocialCounts()
}
async function loadSocialCounts() {
state.socialError = ''
state.followers = []
state.vloggers = []
if (!auth.isLoggedIn) return
if (!Number.isFinite(userId.value) || userId.value <= 0) return
state.socialLoading = true
try {
const [followersRes, vloggersRes] = await Promise.all([
socialApi.getAllFollowers(userId.value),
socialApi.getAllVloggers(userId.value),
])
state.followers = followersRes.followers
state.vloggers = vloggersRes.vloggers
} catch (e) {
state.socialError = e instanceof ApiError ? e.message : String(e)
} finally {
state.socialLoading = false
}
}
async function toggleFollow() {
if (isMe.value) return
if (!auth.isLoggedIn) {
toast.error('请先登录')
await router.push('/account')
return
}
try {
if (isFollowing.value) {
await social.unfollow(userId.value)
toast.info('已取关')
} else {
await social.follow(userId.value)
toast.success('已关注')
}
await loadSocialCounts()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
toast.error('请先登录')
await router.push('/account')
return
}
try {
if (isFollowing.value) {
await social.unfollow(userId.value)
toast.info('已取关')
} else {
await social.follow(userId.value)
toast.success('已关注')
}
await loadSocialCounts()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
}
}
@@ -114,69 +114,69 @@ async function goMessage() {
}
await router.push(`/messages/${userId.value}`)
}
type ListTab = 'followers' | 'following'
const drawer = reactive({
open: false,
tab: 'followers' as ListTab,
})
function openFollowers() {
drawer.tab = 'followers'
drawer.open = true
}
function openFollowing() {
drawer.tab = 'following'
drawer.open = true
}
function closeDrawer() {
drawer.open = false
}
const listTitle = computed(() => (drawer.tab === 'followers' ? '粉丝' : '关注'))
const listItems = computed(() => (drawer.tab === 'followers' ? state.followers : state.vloggers))
async function goUser(id: number) {
drawer.open = false
await router.push(`/u/${id}`)
}
async function goVideo(videoId: number) {
await router.push(`/video/${videoId}`)
}
watch(
() => route.params.id,
async () => {
drawer.open = false
await loadProfile()
},
)
watch(
() => auth.isLoggedIn,
async () => {
await loadSocialCounts()
},
)
onMounted(loadProfile)
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start">
<div class="row" style="gap: 12px; align-items: center">
<UserAvatar :username="state.user?.username ?? 'User'" :id="state.user?.id ?? userId" :size="64" />
<div>
<div class="title" style="margin: 0">@{{ state.user?.username ?? '-' }}</div>
<div class="subtle mono">#{{ state.user?.id ?? userId }}</div>
</div>
</div>
type ListTab = 'followers' | 'following'
const drawer = reactive({
open: false,
tab: 'followers' as ListTab,
})
function openFollowers() {
drawer.tab = 'followers'
drawer.open = true
}
function openFollowing() {
drawer.tab = 'following'
drawer.open = true
}
function closeDrawer() {
drawer.open = false
}
const listTitle = computed(() => (drawer.tab === 'followers' ? '粉丝' : '关注'))
const listItems = computed(() => (drawer.tab === 'followers' ? state.followers : state.vloggers))
async function goUser(id: number) {
drawer.open = false
await router.push(`/u/${id}`)
}
async function goVideo(videoId: number) {
await router.push(`/video/${videoId}`)
}
watch(
() => route.params.id,
async () => {
drawer.open = false
await loadProfile()
},
)
watch(
() => auth.isLoggedIn,
async () => {
await loadSocialCounts()
},
)
onMounted(loadProfile)
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start">
<div class="row" style="gap: 12px; align-items: center">
<UserAvatar :username="state.user?.username ?? 'User'" :id="state.user?.id ?? userId" :size="64" />
<div>
<div class="title" style="margin: 0">@{{ state.user?.username ?? '-' }}</div>
<div class="subtle mono">#{{ state.user?.id ?? userId }}</div>
</div>
</div>
<div class="row">
<button v-if="isMe" class="ghost" type="button" @click="router.push('/account')">我的账号</button>
<template v-else>
@@ -187,72 +187,72 @@ onMounted(loadProfile)
</template>
</div>
</div>
<div v-if="state.loading" class="hint" style="margin-top: 12px">加载中</div>
<div v-else-if="state.error" class="hint bad" style="margin-top: 12px">{{ state.error }}</div>
<div v-else class="row" style="margin-top: 14px">
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowers">
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }}</div>
<div class="metric-label">粉丝</div>
</button>
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowing">
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }}</div>
<div class="metric-label">关注</div>
</button>
<div class="metric static">
<div class="metric-num">{{ state.videos.length }}</div>
<div class="metric-label">作品</div>
</div>
<div v-if="!auth.isLoggedIn" class="subtle" style="margin-left: 8px">登录后可查看粉丝/关注列表</div>
<div v-else-if="state.socialError" class="subtle" style="margin-left: 8px">社交信息加载失败{{ state.socialError }}</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<div class="row" style="justify-content: space-between">
<p class="title" style="margin: 0">作品</p>
<div class="subtle">点击封面进入播放页</div>
</div>
<div v-if="state.videos.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
<div v-else class="video-grid" style="margin-top: 12px">
<button v-for="v in state.videos" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
<div class="video-meta">
<div class="video-title">{{ v.title }}</div>
<div class="video-sub subtle"> {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
</div>
</button>
</div>
</div>
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
<div class="drawer">
<div class="drawer-head">
<div class="drawer-title">{{ listTitle }}</div>
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
</div>
<div class="drawer-body">
<div v-if="state.socialLoading" class="drawer-hint">加载中</div>
<div v-else-if="state.socialError" class="drawer-hint bad">{{ state.socialError }}</div>
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
<button v-for="u in listItems" :key="u.id" class="user-row" type="button" @click="goUser(u.id)">
<UserAvatar :username="u.username" :id="u.id" :size="40" />
<div class="user-meta">
<div class="user-name">@{{ u.username }}</div>
<div class="user-id mono">#{{ u.id }}</div>
</div>
</button>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
<div v-if="state.loading" class="hint" style="margin-top: 12px">加载中</div>
<div v-else-if="state.error" class="hint bad" style="margin-top: 12px">{{ state.error }}</div>
<div v-else class="row" style="margin-top: 14px">
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowers">
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }}</div>
<div class="metric-label">粉丝</div>
</button>
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowing">
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }}</div>
<div class="metric-label">关注</div>
</button>
<div class="metric static">
<div class="metric-num">{{ state.videos.length }}</div>
<div class="metric-label">作品</div>
</div>
<div v-if="!auth.isLoggedIn" class="subtle" style="margin-left: 8px">登录后可查看粉丝/关注列表</div>
<div v-else-if="state.socialError" class="subtle" style="margin-left: 8px">社交信息加载失败{{ state.socialError }}</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<div class="row" style="justify-content: space-between">
<p class="title" style="margin: 0">作品</p>
<div class="subtle">点击封面进入播放页</div>
</div>
<div v-if="state.videos.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
<div v-else class="video-grid" style="margin-top: 12px">
<button v-for="v in state.videos" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
<div class="video-meta">
<div class="video-title">{{ v.title }}</div>
<div class="video-sub subtle"> {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
</div>
</button>
</div>
</div>
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
<div class="drawer">
<div class="drawer-head">
<div class="drawer-title">{{ listTitle }}</div>
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
</div>
<div class="drawer-body">
<div v-if="state.socialLoading" class="drawer-hint">加载中</div>
<div v-else-if="state.socialError" class="drawer-hint bad">{{ state.socialError }}</div>
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
<button v-for="u in listItems" :key="u.id" class="user-row" type="button" @click="goUser(u.id)">
<UserAvatar :username="u.username" :id="u.id" :size="40" />
<div class="user-meta">
<div class="user-name">@{{ u.username }}</div>
<div class="user-id mono">#{{ u.id }}</div>
</div>
</button>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.ghost {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.055);
@@ -261,42 +261,42 @@ onMounted(loadProfile)
padding: 10px 12px;
cursor: pointer;
}
.ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
.metric {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 12px 14px;
min-width: 120px;
cursor: pointer;
display: grid;
gap: 4px;
text-align: left;
}
.metric.static {
cursor: default;
}
.metric:hover {
background: rgba(255, 255, 255, 0.1);
}
.metric:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.metric-num {
font-size: 18px;
font-weight: 900;
letter-spacing: 0.2px;
}
cursor: pointer;
display: grid;
gap: 4px;
text-align: left;
}
.metric.static {
cursor: default;
}
.metric:hover {
background: rgba(255, 255, 255, 0.1);
}
.metric:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.metric-num {
font-size: 18px;
font-weight: 900;
letter-spacing: 0.2px;
}
.metric-label {
font-size: 12px;
color: var(--muted);
@@ -309,160 +309,160 @@ onMounted(loadProfile)
.hint.bad {
color: var(--danger);
}
.video-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.video-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 800px) {
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.video-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.video-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 800px) {
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.video-card {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
padding: 0;
text-align: left;
}
.video-card:hover {
background: rgba(255, 255, 255, 0.08);
}
.video-cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
display: block;
background: rgba(0, 0, 0, 0.35);
}
.video-meta {
padding: 10px 10px;
}
.video-title {
font-weight: 800;
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-sub {
margin-top: 6px;
font-size: 12px;
}
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
z-index: 120;
display: grid;
justify-items: center;
align-items: center;
padding: 16px;
}
overflow: hidden;
cursor: pointer;
padding: 0;
text-align: left;
}
.video-card:hover {
background: rgba(255, 255, 255, 0.08);
}
.video-cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
display: block;
background: rgba(0, 0, 0, 0.35);
}
.video-meta {
padding: 10px 10px;
}
.video-title {
font-weight: 800;
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-sub {
margin-top: 6px;
font-size: 12px;
}
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
z-index: 120;
display: grid;
justify-items: center;
align-items: center;
padding: 16px;
}
.drawer {
width: min(520px, calc(100vw - 18px));
max-height: min(78vh, 720px);
width: min(520px, calc(100vw - 18px));
max-height: min(78vh, 720px);
background: rgba(13, 18, 29, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px;
border-bottom: 1px solid var(--border);
}
.drawer-title {
font-weight: 900;
}
.drawer-x {
width: 34px;
height: 34px;
}
.drawer-title {
font-weight: 900;
}
.drawer-x {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.drawer-body {
overflow: auto;
padding: 12px 14px;
display: grid;
gap: 10px;
}
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.drawer-body {
overflow: auto;
padding: 12px 14px;
display: grid;
gap: 10px;
}
.drawer-hint {
color: var(--muted);
padding: 12px 0;
}
padding: 12px 0;
}
.drawer-hint.bad {
color: var(--danger);
}
.user-row {
text-align: left;
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: center;
padding: 10px 10px;
.user-row {
text-align: left;
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: center;
padding: 10px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.user-row:hover {
background: rgba(255, 255, 255, 0.08);
}
.user-meta {
min-width: 0;
}
.user-name {
font-weight: 800;
}
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.user-row:hover {
background: rgba(255, 255, 255, 0.08);
}
.user-meta {
min-width: 0;
}
.user-name {
font-weight: 800;
}
.user-id {
font-size: 12px;
color: var(--muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>

File diff suppressed because it is too large Load Diff