5 files modified
3 files added
738 ■■■■■ changed files
src/main/java/cc/mrbird/febs/ai/controller/productPoint/ApiProductPointController.java 17 ●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/controller/productPoint/VideoPlayController.java 237 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/entity/AiProductPoint.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/res/productPoint/ApiProductPointInfoVo.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/service/impl/AiProductPointServiceImpl.java 1 ●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java 1 ●●●● patch | view | raw | blame | history
src/main/resources/static/febs/quant/videoPlayer.html 235 ●●●●● patch | view | raw | blame | history
src/main/resources/static/febs/videoNative.html 243 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/controller/productPoint/ApiProductPointController.java
@@ -13,17 +13,25 @@
import cc.mrbird.febs.ai.res.productPoint.ApiProductPointRecommendVo;
import cc.mrbird.febs.ai.service.AiProductPointService;
import cc.mrbird.febs.common.entity.FebsResponse;
import cc.mrbird.febs.common.utils.LoginUserUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
 * @author Administrator
@@ -69,4 +77,5 @@
        return aiProductPointService.recommend(dto);
    }
}
src/main/java/cc/mrbird/febs/ai/controller/productPoint/VideoPlayController.java
New file
@@ -0,0 +1,237 @@
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.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.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@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";
    @GetMapping("/play/{fileName}/{companyId}")
    public void playVideo(@PathVariable("fileName") String fileName,@PathVariable("companyId") String companyId, HttpServletRequest request, HttpServletResponse response) {
        log.info("开始播放视频文件: {}", fileName);
        // 配置参数
        final int BUFFER_SIZE = 1024 * 1024; // 1MB缓冲区,提高I/O性能
        final long MAX_INITIAL_SEGMENT = 1024 * 1024 * 15; // 最大初始片段大小(15MB),约30秒中等码率视频
        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);
            // 检查是否有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;
                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);
                }
            }
        } catch (Exception e) {
            log.error("播放视频文件时发生错误: {}", fileName, e);
            try {
                // 确保响应状态被设置
                if (!response.isCommitted()) {
                    response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
                }
            } catch (Exception ex) {
                log.error("设置响应状态时发生错误", ex);
            }
        } finally {
            log.info("视频播放请求处理完成: {}", fileName);
        }
    }
    /**
     * 根据文件扩展名获取MIME类型
     */
    private String getContentTypeByExtension(String fileName) {
        String extension = "";
        int lastDotIndex = fileName.lastIndexOf('.');
        if (lastDotIndex > 0) {
            extension = fileName.substring(lastDotIndex).toLowerCase();
        }
        switch (extension) {
            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";
        }
    }
}
src/main/java/cc/mrbird/febs/ai/entity/AiProductPoint.java
@@ -52,4 +52,6 @@
     * 描述
     */
    private String description;
    private String videoName;
}
src/main/java/cc/mrbird/febs/ai/res/productPoint/ApiProductPointInfoVo.java
@@ -47,5 +47,7 @@
     */
    @ApiModelProperty(value = "描述")
    private String description;
    @ApiModelProperty(value = "视屏")
    private String videoName;
}
src/main/java/cc/mrbird/febs/ai/service/impl/AiProductPointServiceImpl.java
@@ -103,6 +103,7 @@
        String id = dto.getId();
        AiProductPoint entity = this.getById(id);
        if (ObjectUtil.isNotNull( entity)){
            apiProductPointInfoVo.setVideoName(entity.getVideoName());
            apiProductPointInfoVo.setIsNormal(entity.getIsNormal());
            apiProductPointInfoVo.setFinderUserName(entity.getFinderUserName());
            apiProductPointInfoVo.setFeedId(entity.getFeedId());
src/main/java/cc/mrbird/febs/common/configure/WebMvcConfigure.java
@@ -67,5 +67,6 @@
        registration.excludePathPatterns("/api/ai/talk/questionListV2");
        registration.excludePathPatterns("/api/ai/company/list");
        registration.excludePathPatterns("/api/ai/common/**");
        registration.excludePathPatterns("/api/ai/videoPlay/**");
    }
}
src/main/resources/static/febs/quant/videoPlayer.html
New file
@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>视频播放器</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background-color: #f0f2f5;
            color: #333;
            line-height: 1.6;
        }
        .container {
            max-width: 900px;
            margin: 30px auto;
            padding: 30px;
            background: #f8f8f8;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .header {
            text-align: center;
            margin-bottom: 40px;
        }
        .header h2 {
            color: #333;
            margin-bottom: 10px;
            font-size: 24px;
        }
        .header p {
            color: #666;
            font-size: 14px;
        }
        .play-container {
            margin-top: 20px;
            padding: 30px;
            background: #fff;
            border-radius: 8px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .play-container h3 {
            margin-bottom: 20px;
            color: #333;
            font-size: 20px;
        }
        video {
            width: 100%;
            max-width: 800px;
            height: auto;
            border-radius: 4px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .control-panel {
            margin-top: 20px;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
        }
        input[type="text"] {
            width: 400px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 14px;
        }
        button {
            padding: 10px 20px;
            background-color: #1E9FFF;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.3s;
        }
        button:hover {
            background-color: #009688;
        }
        .message {
            margin-top: 15px;
            padding: 10px;
            border-radius: 4px;
            font-size: 14px;
        }
        .message.success {
            background-color: #f0f9ff;
            color: #1E9FFF;
            border-left: 4px solid #1E9FFF;
        }
        .message.error {
            background-color: #fff2f0;
            color: #ff4d4f;
            border-left: 4px solid #ff4d4f;
        }
        @media (max-width: 768px) {
            .container {
                margin: 20px;
                padding: 20px;
            }
            input[type="text"] {
                width: 100%;
                max-width: 300px;
            }
            .control-panel {
                flex-direction: column;
                align-items: flex-start;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h2>视频播放器</h2>
            <p>支持视频文件播放和跳跃播放</p>
        </div>
        <!-- 播放区域 -->
        <div class="play-container">
            <h3>视频播放</h3>
            <video id="videoPlayer" controls width="100%" height="auto">
                <source id="videoSource" src="" type="video/mp4">
                您的浏览器不支持视频播放
            </video>
            <div class="control-panel">
                <input type="text" id="videoUrl" placeholder="请输入视频播放地址,例如:/fileUpload/play/example.mp4_1234567890">
                <button id="loadVideo">加载视频</button>
            </div>
            <div id="message" class="message" style="display: none;"></div>
        </div>
    </div>
    <script>
        // 初始化
        function init() {
            // 绑定加载视频按钮事件
            document.getElementById('loadVideo').addEventListener('click', function() {
                var videoUrl = document.getElementById('videoUrl').value.trim();
                if (!videoUrl) {
                    showMessage('请输入视频播放地址', 'error');
                    return;
                }
                loadVideo(videoUrl);
            });
        }
        // 加载视频
        function loadVideo(videoUrl) {
            var videoPlayer = document.getElementById('videoPlayer');
            var videoSource = document.getElementById('videoSource');
            // 根据文件扩展名设置正确的MIME类型
            var extension = videoUrl.split('.').pop().toLowerCase();
            var mimeType = 'video/mp4';
            switch(extension) {
                case 'avi':
                    mimeType = 'video/x-msvideo';
                    break;
                case 'mov':
                    mimeType = 'video/quicktime';
                    break;
                case 'wmv':
                    mimeType = 'video/x-ms-wmv';
                    break;
                case 'flv':
                    mimeType = 'video/x-flv';
                    break;
                case 'webm':
                    mimeType = 'video/webm';
                    break;
                case 'mkv':
                    mimeType = 'video/x-matroska';
                    break;
            }
            videoSource.src = videoUrl;
            videoSource.type = mimeType;
            videoPlayer.load();
            showMessage('视频加载中...', 'success');
            // 视频加载完成事件
            videoPlayer.addEventListener('loadedmetadata', function() {
                showMessage('视频加载完成', 'success');
            });
            // 视频加载错误事件
            videoPlayer.addEventListener('error', function() {
                showMessage('视频加载失败,请检查地址是否正确', 'error');
            });
        }
        // 显示消息
        function showMessage(text, type) {
            var messageDiv = document.getElementById('message');
            messageDiv.textContent = text;
            messageDiv.className = 'message ' + type;
            messageDiv.style.display = 'block';
            // 3秒后自动隐藏
            setTimeout(function() {
                messageDiv.style.display = 'none';
            }, 3000);
        }
        // 页面加载完成后初始化
        window.onload = init;
    </script>
</body>
</html>
src/main/resources/static/febs/videoNative.html
New file
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>视频播放器</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background-color: #f0f2f5;
            color: #333;
            line-height: 1.6;
        }
        .container {
            max-width: 900px;
            margin: 30px auto;
            padding: 30px;
            background: #f8f8f8;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .header {
            text-align: center;
            margin-bottom: 40px;
        }
        .header h2 {
            color: #333;
            margin-bottom: 10px;
            font-size: 24px;
        }
        .header p {
            color: #666;
            font-size: 14px;
        }
        .play-container {
            margin-top: 40px;
            padding: 30px;
            background: #fff;
            border-radius: 8px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .play-container h3 {
            margin-bottom: 20px;
            color: #333;
            font-size: 20px;
        }
        video {
            width: 100%;
            max-width: 800px;
            height: auto;
            border-radius: 4px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .control-panel {
            margin-top: 20px;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
        }
        .input-group {
            display: flex;
            align-items: center;
            gap: 10px;
        }
        input[type="text"] {
            width: 400px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 14px;
        }
        button {
            padding: 10px 20px;
            background-color: #1E9FFF;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.3s;
        }
        button:hover {
            background-color: #009688;
        }
        .message {
            margin-top: 10px;
            padding: 10px;
            border-radius: 4px;
            font-size: 14px;
        }
        .message.success {
            background-color: #f0f9ff;
            color: #1E9FFF;
            border-left: 4px solid #1E9FFF;
        }
        .message.error {
            background-color: #fff2f0;
            color: #ff4d4f;
            border-left: 4px solid #ff4d4f;
        }
        @media (max-width: 768px) {
            .container {
                margin: 20px;
                padding: 20px;
            }
            input[type="text"] {
                width: 100%;
                max-width: 300px;
            }
            .control-panel {
                flex-direction: column;
                align-items: flex-start;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h2>视频播放器</h2>
            <p>支持视频文件播放和跳跃播放</p>
        </div>
        <!-- 播放区域 -->
        <div class="play-container">
            <h3>视频播放</h3>
            <video id="videoPlayer" controls width="100%" height="auto">
                <source id="videoSource" src="" type="video/mp4">
                您的浏览器不支持视频播放
            </video>
            <div class="control-panel">
                <div class="input-group">
                    <input type="text" id="videoUrl" placeholder="请输入视频播放地址">
                    <button id="loadVideo">加载视频</button>
                </div>
            </div>
            <div id="message" class="message" style="display: none;"></div>
        </div>
    </div>
    <script>
        // 初始化
        function init() {
            // 绑定加载视频按钮事件
            document.getElementById('loadVideo').addEventListener('click', function() {
                var videoUrl = document.getElementById('videoUrl').value.trim();
                if (!videoUrl) {
                    showMessage('请输入视频播放地址', 'error');
                    return;
                }
                loadVideo(videoUrl);
            });
        }
        // 加载视频
        function loadVideo(videoUrl) {
            var videoPlayer = document.getElementById('videoPlayer');
            var videoSource = document.getElementById('videoSource');
            // 根据文件扩展名设置正确的MIME类型
            var extension = videoUrl.split('.').pop().toLowerCase();
            var mimeType = 'video/mp4';
            switch(extension) {
                case 'avi':
                    mimeType = 'video/x-msvideo';
                    break;
                case 'mov':
                    mimeType = 'video/quicktime';
                    break;
                case 'wmv':
                    mimeType = 'video/x-ms-wmv';
                    break;
                case 'flv':
                    mimeType = 'video/x-flv';
                    break;
                case 'webm':
                    mimeType = 'video/webm';
                    break;
                case 'mkv':
                    mimeType = 'video/x-matroska';
                    break;
            }
            videoSource.src = videoUrl;
            videoSource.type = mimeType;
            videoPlayer.load();
            showMessage('视频加载中...', 'success');
            // 视频加载完成事件
            videoPlayer.addEventListener('loadedmetadata', function() {
                showMessage('视频加载完成', 'success');
            });
            // 视频加载错误事件
            videoPlayer.addEventListener('error', function() {
                showMessage('视频加载失败,请检查地址是否正确', 'error');
            });
        }
        // 显示消息
        function showMessage(text, type) {
            var messageDiv = document.getElementById('message');
            messageDiv.textContent = text;
            messageDiv.className = 'message ' + type;
            messageDiv.style.display = 'block';
            // 3秒后自动隐藏
            setTimeout(function() {
                messageDiv.style.display = 'none';
            }, 3000);
        }
        // 页面加载完成后初始化
        window.onload = init;
    </script>
</body>
</html>