Netflix Clone 后端项目 - 我的片单(Watchlist)模块完整文档

Article detail

后端

2026/4/19 · 55 分钟阅读

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);
    }
}

⚠️ 潜在问题提醒(仅指出,不修改):

  • 冗余依赖注入VideoRepositoryContentNegotiatingViewResolver 被注入但未使用,可删除。
  • 拼写错误:返回消息中 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 URLhttp://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 FoundResourceNotFoundException
  • 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

HeadersAuthorization: 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 环境变量建议

变量名示例值
baseUrlhttp://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 数据库中间表

UserVideo 的多对多关联通过中间表 user_watchlist 维护,JPA 会自动处理记录的增删。


以上即为“我的片单”模块的完整代码、描述及 Postman 测试文档。你可直接将其用于项目文档或团队分享。

评论

动作测试