package cc.mrbird.febs.ai.controller.productPoint;
|
|
import cn.hutool.core.util.StrUtil;
|
import io.swagger.annotations.Api;
|
import lombok.RequiredArgsConstructor;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.http.HttpStatus;
|
import org.springframework.validation.annotation.Validated;
|
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMethod;
|
import org.springframework.web.bind.annotation.RestController;
|
|
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletResponse;
|
import java.io.IOException;
|
import java.io.OutputStream;
|
import java.net.URLEncoder;
|
import java.nio.channels.Channels;
|
import java.nio.channels.FileChannel;
|
import java.nio.file.AccessDeniedException;
|
import java.nio.file.Files;
|
import java.nio.file.Path;
|
import java.nio.file.Paths;
|
import java.nio.file.StandardOpenOption;
|
import java.time.Instant;
|
import java.time.ZoneId;
|
import java.time.format.DateTimeFormatter;
|
import java.util.Date;
|
|
@Slf4j
|
@Validated
|
@RestController
|
@RequiredArgsConstructor
|
@RequestMapping(value = "/api/ai/videoPlay")
|
@Api(value = "VideoPlayController", tags = "AI-视频播放")
|
public class VideoPlayController {
|
|
// 基础上传目录(生产环境建议从配置文件读取)
|
public static final String baseUploadDir = "/home/javaweb/webresource/ai/file";
|
|
// 缓冲区大小(仅用于零拷贝 fallback,一般不使用)
|
private static final int BUFFER_SIZE = 64 * 1024;
|
|
// 日期格式化 RFC 1123
|
private static final DateTimeFormatter RFC_1123_FORMATTER =
|
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("GMT"));
|
|
@GetMapping("/play/{fileName}/{companyId}")
|
public void playVideo(@PathVariable("fileName") String fileName,
|
@PathVariable("companyId") String companyId,
|
HttpServletRequest request,
|
HttpServletResponse response) {
|
|
// 1. 安全校验:防止路径遍历
|
if (!isSafePath(companyId, fileName)) {
|
log.warn("非法文件访问尝试: companyId={}, fileName={}", companyId, fileName);
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
return;
|
}
|
|
Path filePath = getSecureFilePath(companyId, fileName);
|
if (filePath == null || !Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
response.setStatus(HttpStatus.NOT_FOUND.value());
|
return;
|
}
|
|
// 处理 HEAD 请求(仅返回头部,不传输内容)
|
boolean isHead = "HEAD".equalsIgnoreCase(request.getMethod());
|
try {
|
long fileSize = Files.size(filePath);
|
long lastModified = Files.getLastModifiedTime(filePath).toMillis();
|
String contentType = getContentTypeByExtension(fileName);
|
String etag = String.format("\"%d-%d\"", fileSize, lastModified);
|
|
// 设置通用响应头
|
response.setContentType(contentType);
|
response.setHeader("Accept-Ranges", "bytes");
|
response.setHeader("ETag", etag);
|
response.setHeader("Last-Modified", formatLastModified(lastModified));
|
response.setHeader("Cache-Control", "public, max-age=86400"); // 缓存1天
|
// Content-Disposition 支持中文文件名(RFC 5987)
|
String encodedFileName = URLEncoder.encode(fileName, "UTF-8")
|
.replaceAll("\\+", "%20");
|
response.setHeader("Content-Disposition",
|
"inline; filename*=UTF-8''" + encodedFileName);
|
|
// HEAD 请求:设置头部后直接返回
|
if (isHead) {
|
response.setContentLengthLong(fileSize);
|
response.setStatus(HttpStatus.OK.value());
|
return;
|
}
|
|
// 2. 处理 If-Range 条件请求
|
String rangeHeader = request.getHeader("Range");
|
String ifRange = request.getHeader("If-Range");
|
boolean ignoreRange = false;
|
if (ifRange != null && !normalizeEtag(ifRange).equals(normalizeEtag(etag))) {
|
ignoreRange = true;
|
}
|
|
// 3. 根据 Range 头决定响应方式
|
if (rangeHeader != null && rangeHeader.startsWith("bytes=") && !ignoreRange) {
|
handleRangeRequest(response, filePath, fileSize, rangeHeader);
|
} else {
|
handleFullRequest(response, filePath, fileSize);
|
}
|
|
} catch (AccessDeniedException e) {
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
} catch (IOException e) {
|
// 客户端断开连接不打印错误堆栈
|
if (!response.isCommitted() && e.getMessage() != null &&
|
(e.getMessage().contains("Broken pipe") || e.getMessage().contains("Connection reset"))) {
|
log.debug("客户端提前断开连接: {}", fileName);
|
} else {
|
log.error("视频文件传输异常: {}", fileName, e);
|
if (!response.isCommitted()) {
|
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
}
|
}
|
} catch (Exception e) {
|
log.error("视频播放未知异常: {}", fileName, e);
|
if (!response.isCommitted()) {
|
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
}
|
}
|
}
|
|
/**
|
* 处理完整文件请求(HTTP 200 OK),使用零拷贝
|
*/
|
private void handleFullRequest(HttpServletResponse response,
|
Path filePath,
|
long fileSize) throws IOException {
|
response.setStatus(HttpStatus.OK.value());
|
response.setContentLengthLong(fileSize);
|
|
try (FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ);
|
OutputStream os = response.getOutputStream()) {
|
long transferred = 0;
|
long position = 0;
|
while (transferred < fileSize) {
|
long bytes = channel.transferTo(position + transferred,
|
fileSize - transferred,
|
Channels.newChannel(os));
|
if (bytes <= 0) break;
|
transferred += bytes;
|
}
|
os.flush();
|
}
|
}
|
|
/**
|
* 处理 Range 请求(HTTP 206 Partial Content),支持单段范围
|
*/
|
private void handleRangeRequest(HttpServletResponse response,
|
Path filePath,
|
long fileSize,
|
String rangeHeader) throws IOException {
|
// 解析 Range: bytes=start-end
|
String rangeValue = rangeHeader.substring("bytes=".length());
|
String[] parts = rangeValue.split("-", 2);
|
long start = 0;
|
long end = fileSize - 1;
|
|
try {
|
if (!parts[0].isEmpty()) {
|
start = Long.parseLong(parts[0]);
|
}
|
if (parts.length > 1 && !parts[1].isEmpty()) {
|
end = Long.parseLong(parts[1]);
|
}
|
} catch (NumberFormatException e) {
|
log.warn("无效的 Range 头格式: {}", rangeHeader);
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
return;
|
}
|
|
// 范围校验
|
if (start > end || start >= fileSize) {
|
response.setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
|
response.setHeader("Content-Range", "bytes */" + fileSize);
|
return;
|
}
|
if (end >= fileSize) {
|
end = fileSize - 1;
|
}
|
|
long contentLength = end - start + 1;
|
response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
|
response.setContentLengthLong(contentLength);
|
response.setHeader("Content-Range", String.format("bytes %d-%d/%d", start, end, fileSize));
|
|
// 零拷贝传输指定范围
|
try (FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ);
|
OutputStream os = response.getOutputStream()) {
|
long transferred = 0;
|
long position = start;
|
while (transferred < contentLength) {
|
long bytes = channel.transferTo(position + transferred,
|
contentLength - transferred,
|
Channels.newChannel(os));
|
if (bytes <= 0) break;
|
transferred += bytes;
|
}
|
os.flush();
|
}
|
}
|
|
/**
|
* 安全校验:防止路径遍历攻击
|
*/
|
private boolean isSafePath(String companyId, String fileName) {
|
if (StrUtil.isBlank(companyId) || StrUtil.isBlank(fileName)) {
|
return false;
|
}
|
// 限制字符集,禁止 .. 和以点开头
|
boolean companyIdValid = companyId.matches("^[a-zA-Z0-9_-]{1,64}$");
|
boolean fileNameValid = fileName.matches("^[a-zA-Z0-9._-]{1,255}$") &&
|
!fileName.startsWith(".") &&
|
!fileName.contains("..");
|
return companyIdValid && fileNameValid;
|
}
|
|
/**
|
* 获取经过路径遍历防护的安全文件路径
|
*/
|
private Path getSecureFilePath(String companyId, String fileName) {
|
try {
|
Path basePath = Paths.get(baseUploadDir).normalize();
|
Path resolved = basePath.resolve(companyId).resolve(fileName).normalize();
|
if (!resolved.startsWith(basePath)) {
|
return null;
|
}
|
return resolved;
|
} catch (Exception e) {
|
return null;
|
}
|
}
|
|
/**
|
* 根据文件扩展名获取 MIME 类型
|
*/
|
private String getContentTypeByExtension(String fileName) {
|
String ext = "";
|
int lastDot = fileName.lastIndexOf('.');
|
if (lastDot > 0) {
|
ext = fileName.substring(lastDot).toLowerCase();
|
}
|
switch (ext) {
|
case ".mp4":
|
return "video/mp4";
|
case ".avi":
|
return "video/x-msvideo";
|
case ".mov":
|
return "video/quicktime";
|
case ".wmv":
|
return "video/x-ms-wmv";
|
case ".flv":
|
return "video/x-flv";
|
case ".webm":
|
return "video/webm";
|
case ".mkv":
|
return "video/x-matroska";
|
case ".m3u8":
|
return "application/vnd.apple.mpegurl";
|
case ".ts":
|
return "video/MP2T";
|
default:
|
return "application/octet-stream";
|
}
|
}
|
|
/**
|
* 格式化 Last-Modified 头(RFC 1123)
|
*/
|
private String formatLastModified(long millis) {
|
return RFC_1123_FORMATTER.format(Instant.ofEpochMilli(millis));
|
}
|
|
/**
|
* 规范化 ETag 值(去掉首尾双引号)
|
*/
|
private String normalizeEtag(String etag) {
|
if (etag == null) {
|
return null;
|
}
|
if (etag.startsWith("\"") && etag.endsWith("\"")) {
|
return etag.substring(1, etag.length() - 1);
|
}
|
return etag;
|
}
|
}
|