一、工具类(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,可能导致读取长度错误。- 存在未使用的导入(如
CouchbaseProperties、SslProperties)。
四、控制器层(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 URL:
http://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 | 预期状态码 | 说明 |
|---|---|---|---|
| 完整下载 | 无 Range | 200 OK | 返回完整视频,Content-Length 为文件大小 |
| 分片请求 | Range: bytes=0-1023 | 206 Partial Content | 返回前 1024 字节,Content-Range 头包含范围信息 |
| 非法 Range | Range: bytes=999999999- | 416 Range Not Satisfiable | 返回 Content-Range: bytes */文件大小 |
| 无效 Token | token 参数缺失或无效 | 401 Unauthorized | 由 JwtAuthenticationFilter 拦截 |
④ 访问图片 – 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 环境变量建议
| 变量名 | 示例值 |
|---|---|
baseUrl | http://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 请求未正确处理 | 检查 parseRangeHeader 和 createRangeResource 逻辑 |
评论