一、业务服务接口层(Service)

Article detail

后端

2026/4/19 · 66 分钟阅读

一、业务服务接口层(Service)

本文档包含视频管理功能的完整代码实现、功能描述以及 Postman API 测试指南。


一、业务服务接口层(Service)

1.1 VideoSerivice - 视频服务接口

路径com.netflix.clone.demo.service.VideoSerivice
功能:定义视频管理相关的业务方法契约,包括管理员 CRUD 操作和普通用户浏览视频的接口。

package com.netflix.clone.demo.service;

import com.netflix.clone.demo.dto.request.VideoRequest;
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 jakarta.validation.Valid;

import java.util.List;

public interface VideoSerivice {
    MessageResponse createVideoByAdmin(@Valid VideoRequest videoRequest);

    PageResponse<VideoResponse> getAllAdminVideos(int page, int size, String search);

    MessageResponse updateVideoAdmin(Long id, @Valid VideoRequest videoRequest);

    MessageResponse deleteVideoByAdmin(Long id);

    MessageResponse toggleVideoPublishStatusByAdmin(Long id, boolean value);

    VideoStatsResponse getAdminStats();

    PageResponse<VideoResponse> getPublishedVideos(int page, int size, String search, String email);

    List<VideoResponse> getFeaturedVideos();
}

⚠️ 拼写错误提醒(仅指出,不修改):

  • 接口名 VideoSerivice 应为 VideoServiceService 拼写错误)。
  • 返回类型 VideoStatsResponse 在方法 getAdminStats 中使用,但实际存在的 DTO 是 VideoStatusResponse,需确保命名一致。

二、业务服务实现层(Service.Impl)

2.1 VideoServiceImpl - 视频服务实现类

路径com.netflix.clone.demo.service.Impl.VideoServiceImpl
功能:实现 VideoSerivice 接口,处理视频的增删改查、发布状态切换、统计信息获取以及普通用户的视频浏览(支持搜索和 watchlist 状态标记)。

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.request.VideoRequest;
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.Video;
import com.netflix.clone.demo.service.VideoSerivice;
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.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

@Service
public class VideoServiceImpl implements VideoSerivice {

    @Autowired
    private VideoRepository videoRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ServiceUtils serviceUtils;

    /**
     * 管理员创建新视频
     */
    @Override
    public MessageResponse createVideoByAdmin(VideoRequest videoRequest) {
        Video video = new Video();
        video.setTitle(videoRequest.getTitle());
        video.setDescription(videoRequest.getDescription());
        video.setYear(videoRequest.getYear());
        video.setRating(videoRequest.getRating());
        video.setDuration(videoRequest.getDuration());
        video.setSrcUuid(videoRequest.getSrc());
        video.setPosterUuid(videoRequest.getPoster());
        video.setPublished(videoRequest.isPublished());
        video.setCategories(videoRequest.getCategories() != null ? videoRequest.getCategories() :
                List.of());
        videoRepository.save(video);
        return new MessageResponse("Video created successfully.");
    }

    /**
     * 管理员分页获取所有视频(支持搜索)
     */
    @Override
    public PageResponse<VideoResponse> getAllAdminVideos(int page, int size, String search) {
        Pageable pageable = PaginationUtils.createPageRequest(page, size, "id");
        Page<Video> videoPage;

        if (search != null && !search.trim().isEmpty()) {
            videoPage = videoRepository.searchVideos(search.trim(), pageable);
        } else {
            videoPage = videoRepository.findAll(pageable);
        }
        return PaginationUtils.toPageResponse(videoPage, VideoResponse::fromEntity);
    }

    /**
     * 管理员更新视频信息
     */
    @Override
    public MessageResponse updateVideoAdmin(Long id, VideoRequest videoRequest) {
        Video video = new Video();
        video.setId(id);
        video.setTitle(videoRequest.getTitle());
        video.setDescription(videoRequest.getDescription());
        video.setYear(videoRequest.getYear());
        video.setRating(videoRequest.getRating());
        video.setDuration(videoRequest.getDuration());
        video.setSrcUuid(videoRequest.getSrc());
        video.setPosterUuid(videoRequest.getPoster());
        video.setPublished(videoRequest.isPublished());
        video.setCategories(videoRequest.getCategories() != null ? videoRequest.getCategories() :
                List.of());
        videoRepository.save(video);
        return new MessageResponse("Video updated successfully.");
    }

    /**
     * 管理员删除视频
     */
    @Override
    public MessageResponse deleteVideoByAdmin(Long id) {
        if (!videoRepository.existsById(id)) {
            throw new IllegalArgumentException("Video not found: " + id);
        }
        videoRepository.deleteById(id);
        return new MessageResponse("Video deleted successfully.");
    }

    /**
     * 管理员切换视频发布状态
     */
    @Override
    public MessageResponse toggleVideoPublishStatusByAdmin(Long id, boolean status) {
        Video video = serviceUtils.getVideoByIdOrThrow(id);
        video.setPublished(status);
        videoRepository.save(video);
        return new MessageResponse("Video publish status updated successfully.");
    }

    /**
     * 管理员获取视频统计数据
     */
    @Override
    public VideoStatsResponse getAdminStats() {
        long totalVideo = userRepository.count();
        long publishedVideos = videoRepository.countPublishedVideos();
        long totalDuration = videoRepository.getTotalDuration();

        return new VideoStatsResponse(totalVideo, publishedVideos, totalDuration);
    }

    /**
     * 普通用户获取已发布视频列表(分页、搜索、并标记当前用户的 watchlist 状态)
     */
    @Override
    public PageResponse<VideoResponse> getPublishedVideos(int page, int size, String search, String email) {
        Pageable pageable = PaginationUtils.createPageRequest(page, size, "id");
        Page<Video> videoPage;
        if (search != null && !search.trim().isEmpty()) {
            videoPage = videoRepository.searchPublishedVideos(search.trim(), pageable);
        } else {
            videoPage = videoRepository.findPublishedVideos(pageable);
        }
        List<Video> videos = videoPage.getContent();

        Set<Long> watchlistIds = Set.of();
        if (!videos.isEmpty()) {
            try {
                List<Long> videoIds = videos.stream().map(Video::getId).toList();
                watchlistIds = userRepository.findWatchlistVideoIds(email, videoIds);
            } catch (Exception e) {
                watchlistIds = Set.of();
            }
        }
        Set<Long> finalWatchlistIds = watchlistIds;
        videos.forEach(video -> video.setIsInWatchlist(finalWatchlistIds.contains(video.getId())));

        List<VideoResponse> videoResponses = videos.stream().map(VideoResponse::fromEntity).toList();
        return PaginationUtils.toPageResponse(videoPage, videoResponses);
    }

    /**
     * 获取推荐/精选视频(随机返回 5 个已发布视频)
     */
    @Override
    public List<VideoResponse> getFeaturedVideos() {
        Pageable pageable = PageRequest.of(0, 5);
        List<Video> videos = videoRepository.findRandomPulishedVideos(pageable);

        return videos.stream().map(VideoResponse::fromEntity).toList();
    }
}

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

  • getAdminStats 方法中 totalVideo = userRepository.count() 错误地统计了用户数量,而非视频总数。
  • updateVideoAdmin 方法使用 new Video() 并设置 ID 后直接 save,可能导致未传入的字段被覆盖为默认值(如 null)。应查询现有实体后更新。
  • findRandomPulishedVideos 方法名拼写错误(Pulished 应为 Published),且 VideoRepository 中需定义该方法。
  • VideoStatsResponse 类名与之前定义的 VideoStatusResponse 不一致。

三、控制器层(Controller)

3.1 VideoController - 视频管理控制器

路径com.netflix.clone.demo.controller.VideoController
功能:提供管理员视频管理 API 和普通用户浏览视频的 API,所有管理员接口均需 ADMIN 角色权限。

package com.netflix.clone.demo.controller;

import com.netflix.clone.demo.dto.request.VideoRequest;
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.VideoSerivice;
import jakarta.validation.Valid;
import org.hibernate.query.Page;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/videos")
public class VideoController {

    @Autowired
    private VideoSerivice videoSerivice;

    /**
     * 管理员创建视频
     */
    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping("/admin")
    public ResponseEntity<MessageResponse> createVideoByAdmin(
            @Valid @RequestBody VideoRequest videoRequest
    ) {
        return ResponseEntity.ok(videoSerivice.createVideoByAdmin(videoRequest));
    }

    /**
     * 管理员分页获取所有视频(支持搜索)
     */
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public ResponseEntity<PageResponse<VideoResponse>> getAllAdminVideos(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String search
    ) {
        return ResponseEntity.ok(videoSerivice.getAllAdminVideos(page, size, search));
    }

    /**
     * 管理员更新视频
     */
    @PreAuthorize("hasRole('ADMIN')")
    @PutMapping("/admin/{id}")
    public ResponseEntity<MessageResponse> updateVideoByAdmin(
            @PathVariable Long id, @Valid @RequestBody
            VideoRequest videoRequest
    ) {
        return ResponseEntity.ok(videoSerivice.updateVideoAdmin(id, videoRequest));
    }

    /**
     * 管理员删除视频
     */
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/admin/{id}")
    public ResponseEntity<MessageResponse> deleteVideoByAdmin(
            @PathVariable Long id
    ) {
        return ResponseEntity.ok(videoSerivice.deleteVideoByAdmin(id));
    }

    /**
     * 管理员切换视频发布状态
     */
    @PreAuthorize("hasRole('ADMIN')")
    @PatchMapping("/admin/{id}/publish")
    public ResponseEntity<MessageResponse> toggleVideoPublishStatusByAdmin(
            @PathVariable Long id, @RequestParam boolean value
    ) {
        return ResponseEntity.ok(videoSerivice.toggleVideoPublishStatusByAdmin(id, value));
    }

    /**
     * 管理员获取视频统计数据
     */
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/stats")
    public ResponseEntity<VideoStatsResponse> getAdminStats() {
        return ResponseEntity.ok(videoSerivice.getAdminStats());
    }

    /**
     * 普通用户获取已发布视频列表(分页、搜索、自动标记 watchlist 状态)
     */
    @GetMapping("/published")
    public ResponseEntity<PageResponse<VideoResponse>> getPublishedVideos(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String search,
            Authentication authentication
    ) {
        String email = authentication.getName();
        PageResponse<VideoResponse> response = videoSerivice.getPublishedVideos(page, size, search, email);
        return ResponseEntity.ok(response);
    }

    /**
     * 获取精选/推荐视频(无需认证)
     */
    @GetMapping("/featured")
    public ResponseEntity<List<VideoResponse>> getFeaturedVideos() {
        List<VideoResponse> response = videoSerivice.getFeaturedVideos();
        return ResponseEntity.ok(response);
    }
}

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

  • 导入了未使用的 org.hibernate.query.Page,应删除。
  • VideoStatsResponse 类名与之前定义的 VideoStatusResponse 不一致,可能导致编译错误。

四、依赖的 Repository 方法说明

为使 VideoServiceImpl 正常编译运行,VideoRepositoryUserRepository 需补充以下方法声明(此处不提供代码,仅说明):

Repository方法用途
VideoRepositoryPage<Video> searchVideos(String search, Pageable pageable)管理员搜索视频(标题、描述等)
VideoRepositoryPage<Video> searchPublishedVideos(String search, Pageable pageable)用户搜索已发布视频
VideoRepositoryPage<Video> findPublishedVideos(Pageable pageable)分页获取已发布视频
VideoRepositorylong countPublishedVideos()统计已发布视频数量
VideoRepositorylong getTotalDuration()获取所有视频总时长
VideoRepositoryList<Video> findRandomPulishedVideos(Pageable pageable)随机获取已发布视频
UserRepositorySet<Long> findWatchlistVideoIds(String email, List<Long> videoIds)查询用户 watchlist 中包含的视频 ID 集合

五、Postman API 测试指南

5.1 基础配置

  • Base URLhttp://localhost:8080
  • 认证要求
    • 所有 /api/videos/admin/** 接口需要 ADMIN 角色。
    • /api/videos/published 需要用户认证(JWT Token)。
    • /api/videos/featured 无需认证。

5.2 获取 JWT Token

管理员登录

请求

  • Method: POST
  • URL: http://localhost:8080/api/auth/login
  • Body (JSON):
    {
        "email": "admin@example.com",
        "password": "adminPassword"
    }
    

响应

{
    "token": "eyJhbGciOiJIUzM4NCJ9...",
    "email": "admin@example.com",
    "fullName": "Admin User",
    "role": "ADMIN"
}

普通用户登录

同理,获取普通用户 Token 用于测试 /published 接口。

5.3 管理员接口测试

以下接口均需在 Headers 中添加 Authorization: Bearer <admin_token>

① 创建视频 – POST /api/videos/admin

请求体

{
    "title": "The Matrix",
    "description": "A computer hacker learns about the true nature of reality...",
    "year": 1999,
    "rating": "R",
    "duration": 8160,
    "src": "matrix-video-uuid",
    "poster": "matrix-poster-uuid",
    "published": true,
    "categories": ["Action", "Sci-Fi"]
}

预期响应

{
    "message": "Video created successfully."
}

② 分页获取所有视频 – GET /api/videos/admin

请求示例

  • 无搜索:GET /api/videos/admin?page=0&size=10
  • 带搜索:GET /api/videos/admin?page=0&size=10&search=Matrix

预期响应

{
    "content": [
        {
            "id": 1,
            "title": "The Matrix",
            "description": "...",
            "year": 1999,
            "rating": "R",
            "duration": 8160,
            "src": "http://localhost:8080/api/files/video/matrix-video-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": null
        }
    ],
    "totalElements": 100,
    "totalPages": 10,
    "number": 0,
    "size": 10
}

③ 更新视频 – PUT /api/videos/admin/{id}

请求体:同创建视频。

④ 删除视频 – DELETE /api/videos/admin/{id}

预期响应

{
    "message": "Video deleted successfully."
}

⑤ 切换发布状态 – PATCH /api/videos/admin/{id}/publish?value=true

预期响应

{
    "message": "Video publish status updated successfully."
}

⑥ 获取统计数据 – GET /api/videos/admin/stats

预期响应

{
    "totalVideos": 1520,
    "publishedVideos": 1325,
    "totalDuration": 489600
}

5.4 普通用户接口测试

⑦ 获取已发布视频列表 – GET /api/videos/published

HeadersAuthorization: Bearer <user_token>

请求示例

  • GET /api/videos/published?page=0&size=10
  • GET /api/videos/published?search=action&page=0&size=10

预期响应:格式同管理员分页查询,但 isInWatchlist 字段会根据当前用户的实际 watchlist 状态返回 truefalse

⑧ 获取精选视频 – GET /api/videos/featured

无需认证

预期响应

[
    {
        "id": 5,
        "title": "Featured Movie 1",
        ...
        "isInWatchlist": false
    },
    ... (最多 5 条)
]

5.5 Postman 环境变量建议

变量名示例值
baseUrlhttp://localhost:8080
adminToken管理员登录后获取的 JWT
userToken普通用户登录后获取的 JWT
videoId已存在的视频 ID

评论

动作测试