Netflix Clone 后端项目技术文档

Article detail

后端

2026/4/18 · 96 分钟阅读

Netflix Clone 后端项目技术文档

一、项目概述

本项目是基于 Spring Boot 3.5.13 和 Java 21 构建的仿 Netflix 视频平台后端服务,提供用户认证、视频管理、片单收藏、文件上传及邮件通知等核心功能。项目采用经典的三层架构(Controller - Service - Repository),并遵循 RESTful API 设计规范。

二、技术栈与环境要求

技术组件版本 / 说明
Java21
Spring Boot3.5.13
Spring Security提供认证与授权
Spring Data JPA持久层框架
MySQL8.x 关系型数据库
JJWT0.12.5,用于生成与校验 JWT Token
Lombok简化实体与 DTO 样板代码
Jakarta Validation请求参数校验
JavaMail邮件发送(使用 Gmail SMTP)

三、Maven 依赖配置 (pom.xml)

以下配置声明了项目父子关系、编译版本、核心 Starter 依赖以及 JWT、MySQL 驱动、Lombok 等工具库。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.13</version>
        <relativePath/>
    </parent>

    <groupId>com.netflix.clone</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>Netflix Clone Demo</name>
    <description>Spring Boot Netflix Clone Application</description>

    <properties>
        <java.version>21</java.version>
        <!-- 统一管理 JWT 版本,便于升级维护 -->
        <jjwt.version>0.12.5</jjwt.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web Starter:包含 Spring MVC 和嵌入式 Tomcat -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Data JPA Starter:基于 Hibernate 的持久层支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- Spring Security Starter:认证、授权、密码加密 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Spring Mail Starter:邮件发送支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <!-- Validation Starter:请求参数校验(@Valid) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- MySQL 驱动(适配 Spring Boot 3.x 的新坐标) -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- JWT 相关依赖,版本由属性统一控制 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok:简化实体与 DTO 的 getter/setter/构造器 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <!-- 打包时排除 Lombok,避免传递依赖问题 -->
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

四、应用配置文件 (application.properties)

该文件包含了数据源、JPA、文件上传、邮件服务、自定义路径等所有运行时的核心配置。为提高安全性,建议将密码等敏感信息替换为环境变量占位符(${...})。

# ==================== 应用基础信息 ====================
spring.application.name=demo

# ==================== MySQL 数据源配置 ====================
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据库连接 URL:关闭 SSL,指定时区,允许公钥检索
spring.datasource.url=jdbc:mysql://localhost:3306/pulsescreen?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.username=root
# 安全建议:实际部署时使用 ${DB_PASSWORD} 环境变量替代明文密码
spring.datasource.password=chen

# ==================== JPA / Hibernate 配置 ====================
spring.jpa.show-sql=false
# DDL 策略:update 会自动更新表结构(开发环境常用,生产建议 validate 或 none)
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true

# ==================== 文件上传配置 ====================
spring.servlet.multipart.enabled=true
# 单个文件最大 2GB(注意大文件上传建议配合分片机制)
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB

# ==================== Tomcat 容器自定义配置 ====================
# 解除对请求体大小的限制(-1 表示不限制)
server.tomcat.max-swallow-size=-1
server.tomcat.max-http-form-post-size=-1

# ==================== 自定义业务路径配置 ====================
# 上传的视频和图片将存储于项目运行目录下的 uploads 文件夹
file.upload.video-dir=uploads/videos
file.upload.image-dir=uploads/images

# ==================== 邮件发送配置(Gmail SMTP) ====================
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=pingguomiaomiao@gmail.com
# 安全建议:实际部署时使用 ${MAIL_PASSWORD} 环境变量
spring.mail.password=Chen20050407.
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

# 前端应用地址(用于生成邮件中的重置链接)
app.frontend.url=http://localhost:4200

五、包结构与模块说明

1. 枚举包 (com.netflix.clone.demo.enums)

Role 枚举
定义用户角色常量,替代硬编码字符串,提高类型安全性与可维护性。

package com.netflix.clone.demo.enums;

public enum Role {
    USER,
    ADMIN
}

2. 请求 DTO 包 (com.netflix.clone.demo.dto.request)

封装客户端提交的数据结构,并集成 Bean Validation 注解进行自动化校验。

ChangePasswordRequest - 修改密码请求

package com.netflix.clone.demo.dto.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class ChangePasswordRequest {
    @NotBlank(message = "Current Password is required")
    private String currentPassword;

    @NotBlank(message = "New Password is required")
    private String newPassword;
}

EmailRequest - 邮箱请求(用于忘记密码/验证)

package com.netflix.clone.demo.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class EmailRequest {
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
}

LoginRequest - 登录请求

package com.netflix.clone.demo.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class LoginRequest {
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;

    @NotBlank(message = "Password is required")
    private String password;
}

ResetPasswordRequest - 重置密码请求(携带 Token)

package com.netflix.clone.demo.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class ResetPasswordRequest {
    @NotBlank
    private String token;

    @NotBlank
    @Size(min = 6, message = "New password must be at least 6 characters long")
    private String newPassword;
}

VideoRequest - 视频创建/更新请求(待实现字段)

package com.netflix.clone.demo.dto.request;

import lombok.Data;

// 视频上传与编辑的请求载体,具体字段可根据业务补充
@Data
public class VideoRequest {
    // 预留字段:title, description, year, rating, categories 等
}

3. 响应 DTO 包 (com.netflix.clone.demo.dto.response)

封装返回给前端的统一数据结构。

EmailValidationResponse - 邮箱可用性校验响应

package com.netflix.clone.demo.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class EmailValidationResponse {
    private boolean exists;
    private boolean available;
}

LoginResponse - 登录成功响应

package com.netflix.clone.demo.dto.response;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
    private String token;
    private String email;
    private String fullName;
    private String role;
}

MessageResponse - 通用消息响应

package com.netflix.clone.demo.dto.response;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageResponse {
    private String message;
}

PageResponse<T> - 分页数据包装类

package com.netflix.clone.demo.dto.response;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResponse<T> {
    private List<T> content;
    private long totalElements;
    private int totalPages;
    private int number;
    private int size;
}

UserResponse - 用户信息响应

package com.netflix.clone.demo.dto.response;

import com.netflix.clone.demo.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.Instant;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserResponse {
    private Long id;
    private String email;
    private String fullname;
    private String role;
    private boolean active;
    private Instant createAt;
    private Instant updateAt;

    public static UserResponse fromEntity(User user) {
        return new UserResponse(
                user.getId(),
                user.getEmail(),
                user.getFullName(),
                user.getRole().name(),
                user.isActive(),
                user.getCreatedAt(),
                user.getUpdatedAt()
        );
    }
}

VideoResponse - 视频详情响应

package com.netflix.clone.demo.dto.response;

import com.netflix.clone.demo.entity.Video;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.Instant;
import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoResponse {
    private Long id;
    private String title;
    private String description;
    private Integer year;
    private String rating;
    private Integer duration;
    private String src;
    private String poster;
    private boolean published;
    private List<String> categories;
    private Instant createdAt;
    private Instant updatedAt;
    private Boolean isInWatchlist;

    public static VideoResponse fromEntity(Video video) {
        VideoResponse response = new VideoResponse(
                video.getId(),
                video.getTitle(),
                video.getDescription(),
                video.getYear(),
                video.getRating(),
                video.getDuration(),
                video.getSrc(),
                video.getPoster(),
                video.isPublished(),
                video.getCategories(),
                video.getCreatedAt(),
                video.getUpdatedAt()
        );
        if (video.getIsInWatchlist() != null) {
            response.setIsInWatchlist(video.getIsInWatchlist());
        }
        return response;
    }
}

VideoStatusResponse - 视频统计响应

package com.netflix.clone.demo.dto.response;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoStatusResponse {
    private long totalVideos;
    private long publishedVideos;
    private long totalDuration;
}

4. 实体包 (com.netflix.clone.demo.entity)

使用 JPA 注解与数据库表进行映射。

User 实体 - 用户表

package com.netflix.clone.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.netflix.clone.demo.enums.Role;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.Instant;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "users")
@Getter
@Setter
@ToString(exclude = {"password", "verificationToken", "passwordResetToken"})
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 150)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(name = "fullname", nullable = false)
    private String fullName;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role = Role.USER;

    @Column(nullable = false)
    private boolean active = true;

    @Column(nullable = false)
    private boolean emailVerified = false;

    @Column(unique = true)
    private String verificationToken;

    private Instant verificationTokenExpiry;

    @Column
    private String passwordResetToken;

    @Column
    private Instant passwordResetTokenExpiry;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    @Column(nullable = false)
    private Instant updatedAt;

    @JsonIgnore
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "user_watchlist",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "video_id")
    )
    private Set<Video> watchlist = new HashSet<>();

    public void addToWatchlist(Video video) {
        this.watchlist.add(video);
    }

    public void removeFromWatchlist(Video video) {
        this.watchlist.remove(video);
    }
}

Video 实体 - 视频表

package com.netflix.clone.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "videos")
@Getter
@Setter
public class Video {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @JsonIgnore
    private String posterUuid;

    @Column(length = 4000)
    private String description;

    private Integer year;
    private String rating;
    private Integer duration;

    @JsonIgnore
    private String srcUuid;

    @Column(nullable = false)
    private boolean published = false;

    @ElementCollection
    @CollectionTable(name = "video_categories", joinColumns = @JoinColumn(name = "video_id"))
    @Column(name = "category")
    private List<String> categories = new ArrayList<>();

    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    @Transient
    @JsonProperty("isInWatchlist")
    private Boolean isInWatchlist;

    @JsonProperty("src")
    public String getSrc() {
        if (srcUuid != null && !srcUuid.isEmpty()) {
            String baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().toUriString();
            return baseUrl + "/api/files/video/" + srcUuid;
        }
        return null;
    }

    @JsonProperty("poster")
    public String getPoster() {
        if (posterUuid != null && !posterUuid.isEmpty()) {
            String baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().toUriString();
            return baseUrl + "/api/files/image/" + posterUuid;
        }
        return null;
    }
}

5. 数据访问层 (com.netflix.clone.demo.dao)

UserRepository 接口

继承 JpaRepository 获得基本 CRUD 方法,后续可扩展自定义查询。

package com.netflix.clone.demo.dao;

import com.netflix.clone.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

6. 异常处理 (com.netflix.clone.demo.exception)

自定义业务异常与全局异常处理器,统一 API 错误响应格式。

自定义异常类

类名触发场景
AccountDeactivatedException账户被禁用
BadCredentialsException认证凭证错误
EmailAlreadyExistsException注册时邮箱已存在
EmailNotVerifiedException邮箱未验证尝试登录
EmailSendingException邮件发送失败
InvalidCredentialsException无效的凭证(如密码错误)
InvalidRoleException非法的角色值
InvalidTokenException无效或过期的令牌
ResourceNotFoundException请求的资源(如用户、视频)不存在

GlobalExceptionHandler 全局异常处理器

捕获上述自定义异常及通用异常,返回统一格式的 JSON 错误响应。

package com.netflix.clone.demo.exception;

import org.apache.catalina.connector.ClientAbortException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;

import java.time.Instant;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<Map<String, Object>> handleBadCredentials(BadCredentialsException ex) {
        log.warn("BadCredentialsException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.UNAUTHORIZED, ex.getMessage());
    }

    @ExceptionHandler(AccountDeactivatedException.class)
    public ResponseEntity<Map<String, Object>> handleAccountDeactivated(AccountDeactivatedException ex) {
        log.warn("AccountDeactivatedException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.FORBIDDEN, ex.getMessage());
    }

    @ExceptionHandler(EmailNotVerifiedException.class)
    public ResponseEntity<Map<String, Object>> handleEmailNotVerified(EmailNotVerifiedException ex) {
        log.warn("EmailNotVerifiedException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.FORBIDDEN, ex.getMessage());
    }

    @ExceptionHandler(EmailSendingException.class)
    public ResponseEntity<Map<String, Object>> handleEmailSendingException(EmailSendingException ex) {
        log.warn("EmailSendingException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
    }

    @ExceptionHandler(InvalidCredentialsException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidCredentialsException(InvalidCredentialsException ex) {
        log.warn("InvalidCredentialsException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException ex) {
        log.warn("ResourceNotFoundException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidToken(InvalidTokenException ex) {
        log.warn("InvalidTokenException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(InvalidRoleException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidRoleException(InvalidRoleException ex) {
        log.warn("InvalidRoleException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ResponseEntity<Map<String, Object>> handleEmailAlreadyExistsException(EmailAlreadyExistsException ex) {
        log.warn("EmailAlreadyExistsException: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.CONFLICT, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .orElse("Invalid request");
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(Map.of("timestamp", Instant.now(),
                        "status", HttpStatus.BAD_REQUEST.value(),
                        "error", message));
    }

    @ExceptionHandler({AsyncRequestNotUsableException.class, ClientAbortException.class})
    public void handleClientAbort(Exception ex) {
        log.debug("Client closed connection during streaming (expected for video seeking/buffering): {}", ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
        log.warn("Exception: {}", ex.getMessage(), ex);
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
    }

    private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
        Map<String, Object> body = Map.of("timestamp", Instant.now(), "error", message);
        return ResponseEntity.status(status).body(body);
    }
}

六、后续开发建议

  1. 完善 Service 层与 Controller 层:当前文档主要覆盖了实体、DTO、异常等基础设施,接下来需要实现核心业务逻辑(如 AuthServiceVideoService)以及对应的 REST API 端点。
  2. Spring Security 集成:配置 SecurityFilterChainUserDetailsService,将 JWT 过滤器加入认证流程。
  3. 文件上传实现:编写 FileController 接收上传请求,并将文件保存至配置的目录。
  4. 单元测试:为 Service 和 Repository 编写测试用例,确保核心逻辑正确性。

文档至此已完整输出。所有代码块均保持原样,仅补充了结构化的章节说明和注释翻译。

评论

动作测试