From 7d022620982d4fda5c1547c78f96224906b30fb6 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:57:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(P2):=20rand.Read=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=20+=20JWT=E9=9A=8F=E6=9C=BA=E5=AF=86=E9=92=A5=20+=20?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E5=8C=96?= =?UTF-8?q?=20+=20=E5=89=8D=E7=AB=AF=E8=B7=AF=E7=94=B1=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 + backend/internal/auth/jwt.go | 139 +++---- backend/internal/video/video_handler.go | 494 ++++++++++++------------ docker-compose.yml | 212 +++++----- frontend/src/router/index.ts | 68 ++-- 5 files changed, 489 insertions(+), 441 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1a8bcd --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# feedsystem_video_go 环境变量模板 +# 复制此文件为 .env 后修改实际值 +# .env 已被 .gitignore 忽略,不会提交到仓库 + +# MySQL +MYSQL_ROOT_PASSWORD=123456 +MYSQL_DATABASE=feedsystem + +# Redis +REDIS_PASSWORD=123456 + +# RabbitMQ +RABBITMQ_USER=admin +RABBITMQ_PASS=password123 + +# JWT (生产环境务必修改为随机强密钥) +JWT_SECRET=change-me-in-production diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index e7e4737..956a2e0 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,65 +1,74 @@ -// internal/auth/jwt.go -package auth - -import ( - "errors" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func jwtSecret() []byte { - secret := os.Getenv("JWT_SECRET") - if secret == "" { - secret = "change-me-in-env" - } - return []byte(secret) -} - -type Claims struct { - AccountID uint `json:"account_id"` - Username string `json:"username"` - jwt.RegisteredClaims -} - -func GenerateToken(accountID uint, username string) (string, error) { - now := time.Now() - - claims := Claims{ - AccountID: accountID, - Username: username, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - return token.SignedString(jwtSecret()) -} - -func ParseToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims( - tokenString, - &Claims{}, - func(token *jwt.Token) (interface{}, error) { - if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, errors.New("unexpected signing method") - } - return jwtSecret(), nil - }, - ) - if err != nil { - return nil, err - } - - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, jwt.ErrTokenInvalidClaims - } - - return claims, nil -} +// 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(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString(jwtSecret()) +} + +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(token *jwt.Token) (interface{}, error) { + if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, errors.New("unexpected signing method") + } + return jwtSecret(), nil + }, + ) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + + return claims, nil +} diff --git a/backend/internal/video/video_handler.go b/backend/internal/video/video_handler.go index 3fd7576..8319698 100644 --- a/backend/internal/video/video_handler.go +++ b/backend/internal/video/video_handler.go @@ -1,241 +1,253 @@ -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/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(400, gin.H{"error": err.Error()}) - return - } - - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - username, err := jwt.GetUsername(c) - if err != nil { - c.JSON(400, 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(400, 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 := randHex(16) + 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 := randHex(16) + 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 { - b := make([]byte, n) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -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(400, gin.H{"error": err.Error()}) - return - } - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "video deleted"}) -} - -func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { - var req ListByAuthorIDRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - 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(400, gin.H{"error": err.Error()}) - return - } - video, err := vh.service.GetDetail(c.Request.Context(), req.ID) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, video) -} - -func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { - var req UpdateLikesCountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "likes count updated"}) -} +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/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(400, gin.H{"error": err.Error()}) + return + } + + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + username, err := jwt.GetUsername(c) + if err != nil { + c.JSON(400, 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(400, 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(400, gin.H{"error": err.Error()}) + return + } + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "video deleted"}) +} + +func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { + var req ListByAuthorIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + 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(400, gin.H{"error": err.Error()}) + return + } + video, err := vh.service.GetDetail(c.Request.Context(), req.ID) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, video) +} + +func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { + var req UpdateLikesCountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "likes count updated"}) +} diff --git a/docker-compose.yml b/docker-compose.yml index 9eb9464..3145017 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,106 +1,106 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.0 - restart: always - environment: - MYSQL_ROOT_PASSWORD: "123456" - 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 -p123456 --silent"] - interval: 5s - timeout: 5s - retries: 20 - - redis: - image: redis:7-alpine - restart: always - command: ["redis-server", "--appendonly", "yes", "--requirepass", "123456"] - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "-a", "123456", "ping"] - interval: 5s - timeout: 3s - retries: 20 - - rabbitmq: - image: rabbitmq:3-management - restart: always - ports: - - "5672:5672" - - "15672:15672" - environment: - RABBITMQ_DEFAULT_USER: admin - RABBITMQ_DEFAULT_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 - 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 - - worker: - build: - context: . - dockerfile: backend/Dockerfile - target: worker - restart: always - 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 - - frontend: - build: - context: . - dockerfile: frontend/Dockerfile - restart: always - ports: - - "5173:80" - depends_on: - - backend - -volumes: - mysql_data: - redis_data: - rabbitmq_data: - backend_uploads: - +version: '3.8' + +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 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p123456 --silent"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: always + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "123456", "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 + 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 + + worker: + build: + context: . + dockerfile: backend/Dockerfile + target: worker + restart: always + 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 + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + restart: always + ports: + - "5173:80" + depends_on: + - backend + +volumes: + mysql_data: + redis_data: + rabbitmq_data: + backend_uploads: + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6f53031..20905d5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,29 +1,39 @@ -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' - -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 }, - { 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 }, - { path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true }, - ], -}) - -export default router +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 { 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 }, + { path: '/settings', name: 'settings', component: SettingsView, meta: { requiresAuth: true } }, + { path: '/u/:id', name: 'user-profile', component: UserProfileView, props: 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