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 boolean isSafePath(String companyId, String fileName) { if (StrUtil.isBlank(companyId) || StrUtil.isBlank(fileName)) { return false; } // companyId 仍然建议保持严格(防止意外目录穿越) if (!companyId.matches("^[a-zA-Z0-9_-]{1,64}$")) { return false; } // 禁止路径遍历和危险字符 if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\") || fileName.contains("\0")) { return false; } // 长度限制 if (fileName.length() > 255) { return false; } return true; } /** * 获取经过路径遍历防护的安全文件路径 */ 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; } }