Netflix Clone 后端项目技术文档
一、项目概述
本项目是基于 Spring Boot 3.5.13 和 Java 21 构建的仿 Netflix 视频平台后端服务,提供用户认证、视频管理、片单收藏、文件上传及邮件通知等核心功能。项目采用经典的三层架构(Controller - Service - Repository),并遵循 RESTful API 设计规范。
二、技术栈与环境要求
| 技术组件 | 版本 / 说明 |
|---|---|
| Java | 21 |
| Spring Boot | 3.5.13 |
| Spring Security | 提供认证与授权 |
| Spring Data JPA | 持久层框架 |
| MySQL | 8.x 关系型数据库 |
| JJWT | 0.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);
}
}
六、后续开发建议
- 完善 Service 层与 Controller 层:当前文档主要覆盖了实体、DTO、异常等基础设施,接下来需要实现核心业务逻辑(如
AuthService、VideoService)以及对应的 REST API 端点。 - Spring Security 集成:配置
SecurityFilterChain及UserDetailsService,将 JWT 过滤器加入认证流程。 - 文件上传实现:编写
FileController接收上传请求,并将文件保存至配置的目录。 - 单元测试:为 Service 和 Repository 编写测试用例,确保核心逻辑正确性。
文档至此已完整输出。所有代码块均保持原样,仅补充了结构化的章节说明和注释翻译。
评论