一、工具类(Util)

Article detail

后端

2026/4/19 · 79 分钟阅读

一、工具类(Util)

本文档包含文件上传、视频流播放、图片访问功能的完整代码实现、功能描述以及 Postman API 测试指南。


一、工具类(Util)

1.1 FileHandlerUtil - 文件处理工具类

路径com.netflix.clone.demo.util.FileHandlerUtil
功能:提供文件操作相关的静态工具方法,包括文件扩展名提取、通过 UUID 查找文件、检测 MIME 类型、解析 HTTP Range 头、创建分片/完整资源等。

package com.netflix.clone.demo.util;

import org.springframework.core.io.InputStreamResource;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;

import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileHandlerUtil {

    private FileHandlerUtil() {
    }

    /**
     * 提取文件扩展名(包含点号)
     * <p>例如 "video.mp4" 返回 ".mp4"</p>
     *
     * @param originalFilename 原始文件名
     * @return 文件扩展名,若无法提取则返回空字符串
     */
    public static String extractFileExtention(String originalFilename) {
        String fileExtention = "";
        if (originalFilename != null && originalFilename.contains(".")) {
            fileExtention = originalFilename.substring(originalFilename.lastIndexOf("."));
        }
        return fileExtention;
    }

    /**
     * 根据 UUID 在指定目录下查找文件
     * <p>文件名的前缀必须与 UUID 匹配</p>
     *
     * @param directory 搜索目录
     * @param uuid      文件 UUID
     * @return 匹配的文件路径
     * @throws Exception 若文件不存在或发生 I/O 错误
     */
    public static Path findFileByUuid(Path directory, String uuid) throws Exception {
        return Files.list(directory)
                .filter(path -> path.getFileName().toString().startsWith(uuid))
                .findFirst()
                .orElseThrow(() -> new RuntimeException("File not found for UUID: " + uuid));
    }

    /**
     * 根据文件名检测视频 MIME 类型
     *
     * @param filename 文件名
     * @return 对应的 Content-Type,默认返回 "video/mp4"
     */
    public static String detectVideoContentType(String filename) {
        if (filename == null) return "video/mp4";

        if (filename.endsWith(".webm")) return "video/webm";
        if (filename.endsWith(".ogg")) return "video/ogg";
        if (filename.endsWith(".mkv")) return "video/x-matroska";
        if (filename.endsWith(".avi")) return "video/x-msvideo";
        if (filename.endsWith(".mov")) return "video/quicktime";
        if (filename.endsWith(".flv")) return "video/x-flv";
        if (filename.endsWith(".wmv")) return "video/x-ms-wmv";
        if (filename.endsWith(".m4v")) return "video/x-m4v";
        if (filename.endsWith(".3gp")) return "video/3gpp";
        if (filename.endsWith(".mpg") || filename.endsWith("mpeg")) return "video/mpeg";

        return "video/mp4";
    }

    /**
     * 根据文件名检测图片 MIME 类型
     *
     * @param filename 文件名
     * @return 对应的 Content-Type,默认返回 "image/jpeg"
     */
    public static String detectImageContentType(String filename) {
        if (filename == null) return "image/jpeg";

        if (filename.endsWith(".png")) return "image/png";
        if (filename.endsWith(".gif")) return "image/gif";
        if (filename.endsWith(".webp")) return "image/webp";

        return "image/jpeg";
    }

    /**
     * 解析 HTTP Range 请求头
     * <p>示例输入 "bytes=0-1023",返回 [0, 1023]</p>
     *
     * @param rangeHeader Range 请求头的值
     * @param fileLenght  文件总长度
     * @return 包含 rangeStart 和 rangeEnd 的数组
     */
    //bytes=0-1023
    public static long[] parseRangeHeader(String rangeHeader, long fileLenght) {
        String[] ranges = rangeHeader.replace("bytes=", "").split("-");

        long rangeStart = Long.parseLong(ranges[0]);
        long rangeEnd = ranges.length > 1 &&
                !ranges[1].isEmpty() ? Long.parseLong(ranges[1]) : fileLenght - 1;
        return new long[]{rangeStart, rangeEnd};
    }

    /**
     * 创建支持 Range 请求的分片资源
     *
     * @param filePath    文件路径
     * @param rangeStart  起始字节位置
     * @param rangeLength 需要读取的字节长度
     * @return 仅包含指定范围数据的 Resource
     * @throws IOException 文件读取异常
     */
    public static Resource createRangeResource(Path filePath, long rangeStart, long rangeLength) throws IOException {
        RandomAccessFile fileReader = new RandomAccessFile(filePath.toFile(), "r");

        fileReader.seek(rangeStart);

        InputStream partialContentStream =
                new InputStream() {

                    private long totalBytesRead = 0;

                    @Override
                    public int read() throws IOException {
                        if (totalBytesRead >= rangeLength) {
                            fileReader.close();
                            return -1;
                        }
                        totalBytesRead++;
                        return fileReader.read();
                    }

                    @Override
                    public int read(byte[] buffer, int offset, int length) throws IOException {
                        if (totalBytesRead >= rangeLength) {
                            fileReader.close();
                            return -1;
                        }

                        long remainingBytes = rangeLength - totalBytesRead;

                        int bytesToRead = (int) Math.min(length, remainingBytes);

                        int bytesActuallyRead = fileReader.read(buffer, offset, bytesToRead);

                        if (bytesActuallyRead > 0) {
                            totalBytesRead += bytesActuallyRead;
                        }
                        if (totalBytesRead >= rangeLength) {
                            fileReader.close();
                        }
                        return bytesActuallyRead;
                    }

                    @Override
                    public void close() throws IOException {
                        fileReader.close();
                    }
                };
        return new InputStreamResource(partialContentStream) {
            @Override
            public long contentLength() {
                return rangeLength;
            }
        };
    }

    /**
     * 创建完整文件的 Resource
     *
     * @param filePath 文件路径
     * @return 文件的 UrlResource
     * @throws IOException 若文件不存在或不可读
     */
    public static Resource createFullResource(Path filePath) throws IOException {
        Resource resource = new UrlResource(filePath.toUri());

        if (!resource.exists() || !resource.isReadable()) {
            throw new IOException("File not found or not readable: " + filePath);
        }
        return resource;
    }
}

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

  • findFileByUuid 方法未关闭 Files.list() 返回的流,可能导致资源泄漏。
  • parseRangeHeader 缺乏对 null、非法格式的防御性校验。
  • 存在重复导入 org.springframework.core.io.InputStreamResource 和未使用的导入。

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

2.1 FileUploadService - 文件上传服务接口

路径com.netflix.clone.demo.service.FileUploadService
功能:定义文件存储和媒体资源访问的业务方法契约。

package com.netflix.clone.demo.service;

import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;

public interface FileUploadService {
    String storeVideoFile(MultipartFile file);

    String storeImageFile(MultipartFile file);

    ResponseEntity<Resource> serveVideo(String uuid, String rangeHeader);

    ResponseEntity<Resource> serveImage(String uuid);
}

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

3.1 FileUploadServiceImpl - 文件上传服务实现类

路径com.netflix.clone.demo.service.Impl.FileUploadServiceImpl
功能:实现文件存储、视频流播放(支持 Range 请求)及图片访问的具体业务逻辑。

package com.netflix.clone.demo.service.Impl;

import com.netflix.clone.demo.service.FileUploadService;
import com.netflix.clone.demo.util.FileHandlerUtil;
import jakarta.annotation.PostConstruct;

import org.springframework.core.io.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.couchbase.CouchbaseProperties;
import org.springframework.boot.autoconfigure.ssl.SslProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;

@Service
public class FileUploadServiceImpl implements FileUploadService {

    private Path videoStorageLocation;
    private Path imageStorageLocation;

    @Value("${file.upload.video-dir:uploads/videos}")
    private String videodir;

    @Value("${file.upload.image-dir:uploads/images}")
    private String imagedir;

    /**
     * 初始化存储目录
     */
    @PostConstruct
    public void init() {
        this.videoStorageLocation = Paths.get(videodir).toAbsolutePath().normalize();
        this.imageStorageLocation = Paths.get(imagedir).toAbsolutePath().normalize();

        try {
            Files.createDirectories(this.videoStorageLocation);
            Files.createDirectories(this.imageStorageLocation);
        } catch (Exception ex) {
            throw new RuntimeException("Could not create the directory where the upload files will be" +
                    "stored.", ex);
        }
    }

    @Override
    public String storeVideoFile(MultipartFile file) {
        return storeFile(file, videoStorageLocation);
    }

    @Override
    public String storeImageFile(MultipartFile file) {
        return storeFile(file, imageStorageLocation);
    }

    private String storeFile(MultipartFile file, Path storageLocation) {
        String fileExtension = FileHandlerUtil.extractFileExtention(file.getOriginalFilename());
        String uuid = UUID.randomUUID().toString();
        String filename = uuid + fileExtension;

        try {
            if (file.isEmpty()) {
                throw new RuntimeException("Failed to store empty file" + filename);
            }
            Path targetLocation = storageLocation.resolve(filename);
            Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
            return uuid;
        } catch (IOException ex) {
            throw new RuntimeException("Failed to store empty file" + filename, ex);
        }
    }

    /**
     * 提供视频流服务,支持 HTTP Range 请求
     */
    @Override
    public ResponseEntity<Resource> serveVideo(String uuid, String rangeHeader) {
        try {
            Path filePath = FileHandlerUtil.findFileByUuid(videoStorageLocation, uuid);
            Resource resource = FileHandlerUtil.createFullResource(filePath);

            String filename = resource.getFilename();
            String contentType = FileHandlerUtil.detectVideoContentType(filename);
            long fileLength = resource.contentLength();

            if (isFullContentRequest(rangeHeader)) {
                return buildFullVideoResponse(resource, contentType, filename, fileLength);
            }

            return buildPartialVideoResponse(filePath, rangeHeader, contentType, filename, fileLength);
        } catch (Exception e) {
            return ResponseEntity.notFound().build();
        }
    }

    private boolean isFullContentRequest(String rangeHeader) {
        return rangeHeader == null || rangeHeader.isEmpty();
    }

    private ResponseEntity<Resource> buildFullVideoResponse(
            Resource resource, String contentType, String filename, long fileLength) {
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\"")
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileLength))
                .body(resource);
    }

    private ResponseEntity<Resource> buildPartialVideoResponse(
            Path filePath, String rangeHeader, String contentType,
            String filename, long fileLength) throws IOException {
        long[] range = FileHandlerUtil.parseRangeHeader(rangeHeader, fileLength);

        long rangeStart = range[0];
        long rangeEnd = range[1];

        if (!isValidRange(rangeStart, rangeEnd, fileLength)) {
            return buildRangeNotStaisfiableResponse(fileLength);
        }

        long contentLength = rangeEnd - rangeStart + 1;
        Resource rangeResource = FileHandlerUtil.createRangeResource(filePath, rangeStart, rangeEnd);

        return ResponseEntity.status(206)
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\"")
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .header(HttpHeaders.CONTENT_RANGE, "bytes " + rangeStart + "-" + rangeEnd + "/" + fileLength)
                .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength))
                .body(rangeResource);
    }

    private ResponseEntity<Resource> buildRangeNotStaisfiableResponse(long fileLength) {
        return ResponseEntity.status(416)
                .header(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength)
                .build();
    }

    private boolean isValidRange(long rangeStart, long rangeEnd, long fileLength) {
        return rangeStart <= rangeEnd && rangeStart >= 0 && rangeEnd < fileLength;
    }

    /**
     * 提供图片访问服务
     */
    @Override
    public ResponseEntity<Resource> serveImage(String uuid) {
        try {
            Path filePath = FileHandlerUtil.findFileByUuid(imageStorageLocation, uuid);
            Resource resource = FileHandlerUtil.createFullResource(filePath);

            String filename = resource.getFilename();
            String contentType = FileHandlerUtil.detectImageContentType(filename);

            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType(contentType))
                    .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" +
                            filename + "\"")
                    .body(resource);
        } catch (Exception ex) {
            return ResponseEntity.notFound().build();
        }
    }
}

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

  • serveVideo 方法中调用 createRangeResource(filePath, rangeStart, rangeEnd),但 createRangeResource 的第三个参数是 rangeLength,此处传入了 rangeEnd,可能导致读取长度错误。
  • 存在未使用的导入(如 CouchbasePropertiesSslProperties)。

四、控制器层(Controller)

4.1 FileUploadController - 文件上传与访问控制器

路径com.netflix.clone.demo.controller.FileUploadController
功能:提供视频/图片上传、视频流播放、图片访问的 RESTful API。

package com.netflix.clone.demo.controller;

import com.netflix.clone.demo.service.FileUploadService;
import org.springframework.core.io.Resource;
import jakarta.mail.Quota;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/files")
public class FileUploadController {
    @Autowired
    private FileUploadService fileUploadService;

    /**
     * 上传视频文件
     *
     * @param file 上传的视频文件
     * @return 包含文件 UUID、原始文件名、文件大小的响应
     */
    @PostMapping("/upload/video")
    public ResponseEntity<Map<String, String>> uploadVideo(
            @RequestParam("file") MultipartFile file) {
        String uuid = fileUploadService.storeVideoFile(file);
        return ResponseEntity.ok(buildUploadResponse(uuid, file));
    }

    /**
     * 上传图片文件
     *
     * @param file 上传的图片文件
     * @return 包含文件 UUID、原始文件名、文件大小的响应
     */
    @PostMapping("/upload/image")
    public ResponseEntity<Map<String, String>> uploadImage(
            @RequestParam("file") MultipartFile file
    ) {
        String uuid = fileUploadService.storeImageFile(file);
        return ResponseEntity.ok(buildUploadResponse(uuid, file));
    }

    private Map<String, String> buildUploadResponse(String uuid, MultipartFile file) {
        Map<String, String> response = new HashMap<>();
        response.put("uuid", uuid);
        response.put("filename", file.getOriginalFilename());
        response.put("size", String.valueOf(file.getSize()));
        return response;
    }

    /**
     * 播放/下载视频(支持 Range 请求)
     * <p>注意:URL 路径为 /api/files/video/{uuid},而非 /api/files/upload/video/{uuid}</p>
     *
     * @param uuid        视频 UUID
     * @param rangeHeader Range 请求头(可选)
     * @param tokenParam  URL 参数中的 token(由 JwtAuthenticationFilter 处理,此处未使用)
     * @return 视频资源响应(200 或 206)
     */
    @GetMapping("/video/{uuid}")
    public ResponseEntity<Resource> serveVideo(
            @PathVariable String uuid,
            @RequestHeader(value = "Range", required = false) String rangeHeader,
            @RequestHeader(value = "token", required = false) String tokenParam
    ) {
        return fileUploadService.serveVideo(uuid, rangeHeader);
    }

    /**
     * 访问图片
     *
     * @param uuid 图片 UUID
     * @return 图片资源响应
     */
    @GetMapping("/image/{uuid}")
    public ResponseEntity<Resource> serveImage(
            @PathVariable String uuid
    ) {
        return fileUploadService.serveImage(uuid);
    }
}

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

  • @RequestHeader(value = "token", required = false) String tokenParam 参数未被使用,可删除。
  • 视频访问的正确路径是 /api/files/video/{uuid},但你可能在测试时使用了 /api/files/upload/video/{uuid},导致 404 错误。

五、Postman API 测试指南

5.1 基础配置

  • Base URLhttp://localhost:8080
  • 认证要求
    • 上传接口:需要 ADMIN 角色(由 SecurityConfig 配置决定,若未特别配置则可能需要认证)。
    • 访问接口:通过 URL 参数 token 传递 JWT(由 JwtAuthenticationFilter 处理)。

5.2 获取 JWT Token(用于访问视频/图片)

以管理员账号登录获取 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"
}

5.3 接口测试详情

① 上传视频 – POST /api/files/upload/video

请求

  • Method: POST
  • URL: http://localhost:8080/api/files/upload/video
  • Headers: Authorization: Bearer <admin_token> (如需认证)
  • Body: 选择 form-data,Key 为 file(类型选择 File),Value 为本地视频文件。

预期响应

{
    "uuid": "d1ea2dc2-fad9-4d19-9299-05144ce47a4c",
    "filename": "sample.mp4",
    "size": "1048576"
}

② 上传图片 – POST /api/files/upload/image

请求

  • Method: POST
  • URL: http://localhost:8080/api/files/upload/image
  • Headers: Authorization: Bearer <admin_token> (如需认证)
  • Body: form-data,Key 为 file,Value 为本地图片文件。

预期响应

{
    "uuid": "abc123-uuid",
    "filename": "poster.jpg",
    "size": "204800"
}

③ 访问视频(完整/分片) – GET /api/files/video/{uuid}

⚠️ 重要:正确路径是 /api/files/video/{uuid},不是 /api/files/upload/video/{uuid}

请求

  • Method: GET
  • URL: http://localhost:8080/api/files/video/d1ea2dc2-fad9-4d19-9299-05144ce47a4c?token=<jwt_token>
  • Headers: 无需额外设置(Range 头可选,浏览器会自动添加)。

测试场景

场景Headers预期状态码说明
完整下载Range200 OK返回完整视频,Content-Length 为文件大小
分片请求Range: bytes=0-1023206 Partial Content返回前 1024 字节,Content-Range 头包含范围信息
非法 RangeRange: bytes=999999999-416 Range Not Satisfiable返回 Content-Range: bytes */文件大小
无效 Tokentoken 参数缺失或无效401 UnauthorizedJwtAuthenticationFilter 拦截

④ 访问图片 – GET /api/files/image/{uuid}

请求

  • Method: GET
  • URL: http://localhost:8080/api/files/image/abc123-uuid?token=<jwt_token>

预期响应

  • 状态码: 200 OK
  • Content-Type: image/jpeg(或根据扩展名检测)
  • 浏览器直接显示图片。

5.4 Postman 环境变量建议

变量名示例值
baseUrlhttp://localhost:8080
adminToken管理员登录后获取的 JWT
videoUuid上传视频后返回的 UUID
imageUuid上传图片后返回的 UUID

在请求 URL 中使用 {{baseUrl}}/api/files/video/{{videoUuid}}?token={{adminToken}}

5.5 常见错误排查

错误现象可能原因检查方法
No static resource 404请求 URL 路径错误(多写了 /upload确认请求 URL 为 /api/files/video/{uuid}
401 Unauthorized未提供 token 参数或 Token 无效检查 URL 是否携带 ?token=xxx,Token 是否过期
File not found for UUID文件不存在或 UUID 错误检查 uploads/videos/ 目录下是否有对应文件
视频无法拖动进度条Range 请求未正确处理检查 parseRangeHeadercreateRangeResource 逻辑

评论

动作测试