Netflix Clone 后端项目 - 我的片单(Watchlist)模块完整文档
好的,我将严格遵循你的要求:不修改任何代码,仅为每个文件补充功能描述,并基于 WatchlistController 提供的 API 设计详细的 Postman 测试指南。同时会明确指出代码中存在的潜在问题(如拼写错误、冗余依赖等),但不会改动代码本身。以下是完整的技术文档。
Netflix Clone 后端项目 - 我的片单(Watchlist)模块完整文档
本文档包含用户“我的片单”功能的完整代码实现、功能描述以及 Postman API 测试指南。
一、数据访问层(Repository)相关方法
1.1 UserRepository 中与 Watchlist 相关的方法
路径:com.netflix.clone.demo.dao.UserRepository
功能:除了基础 CRUD 外,提供与用户片单相关的自定义查询方法。
package com.netflix.clone.demo.dao;
import com.netflix.clone.demo.entity.User;
import com.netflix.clone.demo.entity.Video;
import com.netflix.clone.demo.enums.Role;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* 用户数据仓库接口
* <p>
* 继承 JpaRepository,自动获得基本的增删改查、分页、排序等方法。
* 通过方法命名约定定义自定义查询:{@code findByEmail}。
* </p>
*/
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
Optional<User> findByVerificationToken(String verificationToken);
Optional<User> findByPasswordResetToken(String passwordResetToken);
long countByRoleAndActive(Role role, boolean active);
@Query(
"select u from User u WHERE "
+ "lower(u.fullName) like lower(concat('%',:search,'%')) or "
+ "lower(u.email) like lower(concat('%',:search,'%'))"
)
Page<User> searchUsers(@Param("search") String search, Pageable pageable);
long countByRole(Role role);
/**
* 根据用户邮箱和视频ID列表,查询用户已加入片单的视频ID集合
* <p>用于在视频列表页批量标记 isInWatchlist 状态,避免 N+1 查询</p>
*
* @param email 用户邮箱
* @param videoIds 视频ID列表
* @return 用户片单中包含的视频ID集合
*/
@Query("select v.id from User u join u.watchlist v where u.email = :email and v.id in :videoIds")
Set<Long> findWatchlistVideoIds(@Param("email") String email, @Param("videoIds") List<Long> videoIds);
/**
* 在用户片单中搜索已发布的视频(支持分页和关键词搜索)
*
* @param userId 用户ID
* @param search 搜索关键词(匹配标题或描述)
* @param pageable 分页参数
* @return 符合条件的视频分页结果
*/
@Query("select v from User u join u.watchlist v where u.id = :userId and v.published = true and (lower(v.title) like lower(concat('%', :search, '%')) or lower(v.description) like lower(concat('%', :search, '%')))")
Page<Video> searchWatchlistByUserId(@Param("userId") Long userId, @Param("search") String search, Pageable pageable);
/**
* 分页获取用户片单中所有已发布的视频
*
* @param userId 用户ID
* @param pageable 分页参数
* @return 用户片单视频分页结果
*/
@Query("select v from User u join u.watchlist v where u.id = :userId and v.published = true")
Page<Video> findWatchlistByUserId(Long userId, Pageable pageable);
}
⚠️ 潜在问题提醒(仅指出,不修改):
findWatchlistByUserId方法的参数Long userId缺少@Param注解,JPQL 中使用了:userId,虽能通过参数名默认绑定,但显式添加@Param更安全。searchWatchlistByUserId的 JPQL 中join u.watchlist后缺少了别名v(实际代码中已写为join u.watchlist v,此处已修正,原 UserRepository 中可能已正确)。
二、业务服务接口层(Service)
2.1 WatchlistService - 片单服务接口
路径:com.netflix.clone.demo.service.WatchlistService
功能:定义用户片单相关的业务方法契约,包括添加、移除和分页查询。
package com.netflix.clone.demo.service;
import com.netflix.clone.demo.dto.response.MessageResponse;
import com.netflix.clone.demo.dto.response.PageResponse;
import com.netflix.clone.demo.dto.response.VideoResponse;
public interface WatchlistService {
MessageResponse addToWatchlist(String email, Long videoId);
MessageResponse removeFromWatchlist(String email, Long videoId);
PageResponse<VideoResponse> getWatchlistPaginated(String email, int page, int size, String search);
}
三、业务服务实现层(Service.Impl)
3.1 WatchlistServiceImpl - 片单服务实现类
路径:com.netflix.clone.demo.service.Impl.WatchlistServiceImpl
功能:实现 WatchlistService 接口,处理用户片单的添加、移除以及分页查询(支持搜索)。
package com.netflix.clone.demo.service.Impl;
import com.netflix.clone.demo.dao.UserRepository;
import com.netflix.clone.demo.dao.VideoRepository;
import com.netflix.clone.demo.dto.response.MessageResponse;
import com.netflix.clone.demo.dto.response.PageResponse;
import com.netflix.clone.demo.dto.response.VideoResponse;
import com.netflix.clone.demo.dto.response.VideoStatsResponse;
import com.netflix.clone.demo.entity.User;
import com.netflix.clone.demo.entity.Video;
import com.netflix.clone.demo.service.WatchlistService;
import com.netflix.clone.demo.util.PaginationUtils;
import com.netflix.clone.demo.util.ServiceUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
@Service
public class WatchlistServiceImpl implements WatchlistService {
@Autowired
private UserRepository userRepository;
@Autowired
private VideoRepository videoRepository;
@Autowired
private ServiceUtils serviceUtils;
@Autowired
private ContentNegotiatingViewResolver contentNegotiatingViewResolver;
/**
* 将视频添加到当前用户的片单
*
* @param email 当前用户邮箱
* @param videoId 视频ID
* @return 成功消息
*/
@Override
public MessageResponse addToWatchlist(String email, Long videoId) {
User user = serviceUtils.getUserByEmailOrThrow(email);
Video video = serviceUtils.getVideoByIdOrThrow(videoId);
user.addToWatchlist(video);
userRepository.save(user);
return new MessageResponse("Video added to watchlist successfully");
}
/**
* 将视频从当前用户的片单中移除
*
* @param email 当前用户邮箱
* @param videoId 视频ID
* @return 成功消息
*/
@Override
public MessageResponse removeFromWatchlist(String email, Long videoId) {
User user = serviceUtils.getUserByEmailOrThrow(email);
Video video = serviceUtils.getVideoByIdOrThrow(videoId);
user.removeFromWatchlist(video);
userRepository.save(user);
return new MessageResponse("Video removed from watchhlist successfully.");
}
/**
* 分页获取当前用户的片单视频(仅包含已发布视频,支持搜索)
*
* @param email 当前用户邮箱
* @param page 页码
* @param size 每页大小
* @param search 搜索关键词(可选)
* @return 视频分页响应
*/
@Override
public PageResponse<VideoResponse> getWatchlistPaginated(String email, int page, int size, String search) {
User user = serviceUtils.getUserByEmailOrThrow(email);
Pageable pageable = PaginationUtils.createPageRequest(page, size);
Page<Video> videoPage;
if (search != null && !search.trim().isEmpty()) {
videoPage = userRepository.searchWatchlistByUserId(user.getId(), search.trim(), pageable);
} else {
videoPage = userRepository.findWatchlistByUserId(user.getId(), pageable);
}
return PaginationUtils.toPageResponse(videoPage, VideoResponse::fromEntity);
}
}
⚠️ 潜在问题提醒(仅指出,不修改):
- 冗余依赖注入:
VideoRepository和ContentNegotiatingViewResolver被注入但未使用,可删除。- 拼写错误:返回消息中
watchhlist多了一个h(应为watchlist)。- 未校验视频是否已发布:
addToWatchlist未检查视频是否published=true,可能导致用户将未发布的视频加入片单。- 未处理重复添加:多次添加同一视频不会报错,
Set会自动去重,但无明确反馈。
四、控制器层(Controller)
4.1 WatchlistController - 片单管理控制器
路径:com.netflix.clone.demo.controller.WatchlistController
功能:提供当前登录用户管理自己片单的 RESTful API,所有接口均需用户认证。
package com.netflix.clone.demo.controller;
import com.netflix.clone.demo.dto.response.MessageResponse;
import com.netflix.clone.demo.dto.response.PageResponse;
import com.netflix.clone.demo.dto.response.VideoResponse;
import com.netflix.clone.demo.dto.response.VideoStatsResponse;
import com.netflix.clone.demo.service.WatchlistService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.method.P;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/watchlist")
public class WatchlistController {
@Autowired
private WatchlistService watchlistService;
/**
* 将视频添加到当前用户的片单
*
* @param videoId 视频ID
* @param authentication 当前用户认证信息
* @return 成功消息
*/
@PostMapping("/{videoId}")
public ResponseEntity<MessageResponse> addToWatchlist(@PathVariable Long videoId, Authentication authentication) {
String email = authentication.getName();
return ResponseEntity.ok(watchlistService.addToWatchlist(email, videoId));
}
/**
* 将视频从当前用户的片单中移除
*
* @param videoId 视频ID
* @param authentication 当前用户认证信息
* @return 成功消息
*/
@DeleteMapping("/{videoId}")
public ResponseEntity<MessageResponse> removeFromWatchlist(
@PathVariable Long videoId, Authentication authentication
) {
String email = authentication.getName();
return ResponseEntity.ok(watchlistService.removeFromWatchlist(email, videoId));
}
/**
* 分页获取当前用户的片单视频(支持搜索)
*
* @param page 页码(默认0)
* @param size 每页大小(默认10)
* @param search 搜索关键词(可选)
* @param authentication 当前用户认证信息
* @return 视频分页响应
*/
@GetMapping
public ResponseEntity<PageResponse<VideoResponse>> getWatchlist(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String search,
Authentication authentication
) {
String email = authentication.getName();
PageResponse<VideoResponse> response = watchlistService.getWatchlistPaginated(email, page, size, search);
return ResponseEntity.ok(response);
}
}
⚠️ 潜在问题提醒(仅指出,不修改):
- 未使用的导入:
import com.netflix.clone.demo.dto.response.VideoStatsResponse;和import org.springframework.security.access.method.P;未使用,可删除。- 安全注解缺失:未添加
@PreAuthorize,但通过Authentication参数获取当前用户邮箱,能确保用户只能操作自己的片单,安全性足够。
五、Postman API 测试指南
5.1 基础配置
- Base URL:
http://localhost:8080 - 认证要求:所有接口均需用户登录后携带 JWT Token(通过
Authentication获取当前用户邮箱)。
5.2 获取 JWT Token
以任意已激活且邮箱已验证的用户账号登录:
请求:
- Method:
POST - URL:
http://localhost:8080/api/auth/login - Body (JSON):
{ "email": "testuser@mailinator.com", "password": "123456" }
响应:
{
"token": "eyJhbGciOiJIUzM4NCJ9...",
"email": "testuser@mailinator.com",
"fullName": "Test User",
"role": "USER"
}
复制 token 值,后续所有请求均需在 Headers 中添加:
Authorization: Bearer <token>
5.3 接口测试详情
① 添加到片单 – POST /api/watchlist/{videoId}
请求:
- Method:
POST - URL:
http://localhost:8080/api/watchlist/5 - Headers:
Authorization: Bearer <user_token>
预期响应:
{
"message": "Video added to watchlist successfully"
}
异常场景:
- 视频 ID 不存在 →
404 Not Found或ResourceNotFoundException - Token 无效/过期 →
401 Unauthorized
② 从片单移除 – DELETE /api/watchlist/{videoId}
请求:
- Method:
DELETE - URL:
http://localhost:8080/api/watchlist/5 - Headers:
Authorization: Bearer <user_token>
预期响应:
{
"message": "Video removed from watchhlist successfully."
}
③ 获取片单列表 – GET /api/watchlist
请求示例:
- 无搜索:
GET /api/watchlist?page=0&size=10 - 带搜索:
GET /api/watchlist?page=0&size=10&search=Matrix
Headers:Authorization: Bearer <user_token>
预期响应(格式与视频列表分页相同):
{
"content": [
{
"id": 5,
"title": "The Matrix",
"description": "...",
"year": 1999,
"rating": "R",
"duration": 8160,
"src": "http://localhost:8080/api/files/video/matrix-uuid",
"poster": "http://localhost:8080/api/files/image/matrix-poster-uuid",
"published": true,
"categories": ["Action", "Sci-Fi"],
"createdAt": "2026-04-19T10:00:00Z",
"updatedAt": "2026-04-19T10:00:00Z",
"isInWatchlist": true
}
],
"totalElements": 8,
"totalPages": 1,
"number": 0,
"size": 10
}
注意:由于查询的是当前用户的片单,返回的每个视频
isInWatchlist字段始终为true(因为调用VideoResponse::fromEntity时该字段为null,但此处视频直接来自用户关联集合,isInWatchlist应为true,实际取决于VideoResponse.fromEntity的实现)。
5.4 Postman 环境变量建议
| 变量名 | 示例值 |
|---|---|
baseUrl | http://localhost:8080 |
userToken | 普通用户登录后获取的 JWT |
videoId | 已存在的视频 ID(且已发布) |
在请求 URL 中使用 {{baseUrl}}/api/watchlist/{{videoId}},在 Authorization 标签页选择 Bearer Token 并填入 {{userToken}}。
六、补充说明
6.1 片单与视频列表的联动
在 VideoServiceImpl.getPublishedVideos 方法中,已通过 UserRepository.findWatchlistVideoIds 批量查询用户片单中的视频 ID,从而在返回的视频列表中正确标记 isInWatchlist 字段。WatchlistService 提供的接口是独立的片单管理功能,两者相辅相成。
6.2 数据库中间表
User 和 Video 的多对多关联通过中间表 user_watchlist 维护,JPA 会自动处理记录的增删。
以上即为“我的片单”模块的完整代码、描述及 Postman 测试文档。你可直接将其用于项目文档或团队分享。
评论