| | |
| | | package cc.mrbird.febs.ai.controller.productPoint; |
| | | |
| | | import cc.mrbird.febs.common.utils.LoginUserUtil; |
| | | 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.InputStream; |
| | | import java.io.IOException; |
| | | import java.io.OutputStream; |
| | | import java.io.RandomAccessFile; |
| | | 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-视屏播放") |
| | | @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) { |
| | | log.info("开始播放视频文件: {}", fileName); |
| | | public void playVideo(@PathVariable("fileName") String fileName, |
| | | @PathVariable("companyId") String companyId, |
| | | HttpServletRequest request, |
| | | HttpServletResponse response) { |
| | | |
| | | // 配置参数 |
| | | final int BUFFER_SIZE = 1024 * 1024; // 1MB缓冲区,提高I/O性能 |
| | | final long MAX_INITIAL_SEGMENT = 1024 * 1024 * 15; // 最大初始片段大小(15MB),约30秒中等码率视频 |
| | | // 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 { |
| | | // String companyId = LoginUserUtil.getLoginUser().getCompanyId(); |
| | | |
| | | String uploadDir = baseUploadDir + "/" + companyId; |
| | | Path filePath = Paths.get(uploadDir, fileName); |
| | | |
| | | // 检查文件是否存在 |
| | | if (!Files.exists(filePath)) { |
| | | log.warn("视频文件不存在: {}", filePath); |
| | | response.setStatus(HttpStatus.NOT_FOUND.value()); |
| | | return; |
| | | } |
| | | |
| | | // 检查文件是否为常规文件 |
| | | if (!Files.isRegularFile(filePath)) { |
| | | log.warn("路径不是常规文件: {}", filePath); |
| | | response.setStatus(HttpStatus.BAD_REQUEST.value()); |
| | | return; |
| | | } |
| | | |
| | | // 获取文件大小 |
| | | long fileSize = Files.size(filePath); |
| | | log.debug("视频文件大小: {} bytes", fileSize); |
| | | long lastModified = Files.getLastModifiedTime(filePath).toMillis(); |
| | | String contentType = getContentTypeByExtension(fileName); |
| | | String etag = String.format("\"%d-%d\"", fileSize, lastModified); |
| | | |
| | | // 检查是否有Range请求头 |
| | | String rangeHeader = request.getHeader("Range"); |
| | | if (rangeHeader != null) { |
| | | log.debug("收到Range请求: {}", rangeHeader); |
| | | // 解析Range头,格式为"bytes=start-end" |
| | | String[] ranges = rangeHeader.replace("bytes=", "").split("-"); |
| | | long start = 0, end = fileSize - 1; |
| | | // 设置通用响应头 |
| | | 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); |
| | | |
| | | try { |
| | | if (!ranges[0].isEmpty()) { |
| | | start = Long.parseLong(ranges[0]); |
| | | } |
| | | if (ranges.length > 1 && !ranges[1].isEmpty()) { |
| | | end = Long.parseLong(ranges[1]); |
| | | } else { |
| | | // 如果没有指定结束位置,返回从开始位置到文件末尾的内容 |
| | | // 但限制最大长度,避免一次性传输过大的内容 |
| | | end = Math.min(start + BUFFER_SIZE * 10, fileSize - 1); |
| | | } |
| | | } catch (NumberFormatException e) { |
| | | log.error("无效的Range头格式: {}", rangeHeader, e); |
| | | response.setStatus(HttpStatus.BAD_REQUEST.value()); |
| | | return; |
| | | } |
| | | |
| | | // 确保范围有效 |
| | | if (start > end || start >= fileSize) { |
| | | log.warn("无效的Range范围: {}-{},文件大小: {}", start, end, 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; |
| | | log.debug("处理Range请求: {}-{},长度: {}", start, end, contentLength); |
| | | |
| | | // 设置响应状态和头部信息 |
| | | response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); |
| | | response.setContentType(getContentTypeByExtension(fileName)); |
| | | response.setContentLengthLong(contentLength); |
| | | response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); |
| | | response.setHeader("Accept-Ranges", "bytes"); |
| | | response.setHeader("Content-Disposition", "inline; filename=\"" + fileName + "\""); |
| | | response.setHeader("Cache-Control", "public, max-age=3600"); // 添加缓存控制 |
| | | |
| | | // 使用NIO提高读取性能 |
| | | try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath.toFile(), "r"); |
| | | OutputStream outputStream = response.getOutputStream()) { |
| | | |
| | | randomAccessFile.seek(start); |
| | | byte[] buffer = new byte[BUFFER_SIZE]; |
| | | long remaining = contentLength; |
| | | int bytesRead; |
| | | |
| | | while (remaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { |
| | | outputStream.write(buffer, 0, bytesRead); |
| | | remaining -= bytesRead; |
| | | |
| | | // 定期刷新输出流,避免缓冲区溢出 |
| | | if (remaining % (BUFFER_SIZE * 4) == 0) { |
| | | outputStream.flush(); |
| | | } |
| | | } |
| | | |
| | | // 确保所有数据都被写入 |
| | | outputStream.flush(); |
| | | log.debug("Range请求处理完成: {}-{}", start, end); |
| | | } |
| | | } else { |
| | | log.debug("收到完整文件请求,返回初始片段"); |
| | | |
| | | // 没有Range头,返回文件大小的10分之一和15MB中较小的那个 |
| | | long oneTenthSize = fileSize / 10; |
| | | // 计算10分之一和15MB的较小值 |
| | | long maxInitialSize = Math.min(oneTenthSize, MAX_INITIAL_SEGMENT); |
| | | // 确保至少返回1字节,且不超过文件大小 |
| | | long actualEnd = Math.min(Math.max(maxInitialSize, 1), fileSize - 1); |
| | | log.debug("返回初始片段: {} bytes (10分之一大小: {} bytes, 15MB限制: {} bytes, 文件总大小: {} bytes)", |
| | | actualEnd + 1, oneTenthSize, MAX_INITIAL_SEGMENT, fileSize); |
| | | |
| | | // 设置响应状态和头部信息 |
| | | response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); // 使用206状态码,因为只返回部分内容 |
| | | response.setContentType(getContentTypeByExtension(fileName)); |
| | | response.setContentLengthLong(actualEnd + 1); |
| | | response.setHeader("Content-Range", "bytes 0-" + actualEnd + "/" + fileSize); |
| | | response.setHeader("Accept-Ranges", "bytes"); |
| | | response.setHeader("Content-Disposition", "inline; filename=\"" + fileName + "\""); |
| | | response.setHeader("Cache-Control", "public, max-age=3600"); // 添加缓存控制 |
| | | |
| | | // 流式传输文件的初始片段 |
| | | try (InputStream inputStream = Files.newInputStream(filePath); |
| | | OutputStream outputStream = response.getOutputStream()) { |
| | | byte[] buffer = new byte[BUFFER_SIZE]; |
| | | long written = 0; |
| | | int bytesRead; |
| | | |
| | | while (written <= actualEnd && (bytesRead = inputStream.read(buffer)) != -1) { |
| | | long toWrite = Math.min(bytesRead, actualEnd + 1 - written); |
| | | outputStream.write(buffer, 0, (int)toWrite); |
| | | written += toWrite; |
| | | |
| | | // 定期刷新输出流 |
| | | if (written % (BUFFER_SIZE * 4) == 0) { |
| | | outputStream.flush(); |
| | | } |
| | | |
| | | // 如果已经写完初始片段,则停止 |
| | | if (written > actualEnd) { |
| | | break; |
| | | } |
| | | } |
| | | |
| | | // 确保所有数据都被写入 |
| | | outputStream.flush(); |
| | | log.debug("初始片段传输完成,大小: {} bytes", written); |
| | | } |
| | | // HEAD 请求:设置头部后直接返回 |
| | | if (isHead) { |
| | | response.setContentLengthLong(fileSize); |
| | | response.setStatus(HttpStatus.OK.value()); |
| | | return; |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("播放视频文件时发生错误: {}", fileName, e); |
| | | try { |
| | | // 确保响应状态被设置 |
| | | |
| | | // 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 ex) { |
| | | log.error("设置响应状态时发生错误", ex); |
| | | } |
| | | } finally { |
| | | log.info("视频播放请求处理完成: {}", fileName); |
| | | } catch (Exception e) { |
| | | log.error("视频播放未知异常: {}", fileName, e); |
| | | if (!response.isCommitted()) { |
| | | response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据文件扩展名获取MIME类型 |
| | | * 处理完整文件请求(HTTP 200 OK),使用零拷贝 |
| | | */ |
| | | private String getContentTypeByExtension(String fileName) { |
| | | String extension = ""; |
| | | int lastDotIndex = fileName.lastIndexOf('.'); |
| | | if (lastDotIndex > 0) { |
| | | extension = fileName.substring(lastDotIndex).toLowerCase(); |
| | | 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; |
| | | } |
| | | |
| | | switch (extension) { |
| | | // 范围校验 |
| | | 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 "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; |
| | | } |
| | | } |