feat(chaos): 实现视频分片上传和后台处理功能- 新增视频上传相关控制器、服务接口和实现类
- 实现了视频分片上传、合并和后台处理的逻辑 - 添加了 RabbitMQ 消息队列配置和消息转换器 -优化了 JWT 认证过滤器和日志记录 - 新增了跨域配置
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ build/
|
|||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.idea/
|
/.idea/
|
||||||
|
/chaos/
|
||||||
|
|||||||
@@ -29,4 +29,12 @@ public interface UserMapper extends BaseMapper<User> {
|
|||||||
WHERE u.username = #{username}
|
WHERE u.username = #{username}
|
||||||
""")
|
""")
|
||||||
List<String> findAuthoritiesByUsername(@Param("username") String username);
|
List<String> findAuthoritiesByUsername(@Param("username") String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入用户角色关系
|
||||||
|
* @param id 用户id
|
||||||
|
* @param i 角色id
|
||||||
|
*/
|
||||||
|
@Select("INSERT INTO t_user_role (user_id, role_id) VALUES (#{id}, #{i})")
|
||||||
|
void insertUserRole(Long id, int i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package cn.nopj.chaos_api.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ProcessVideoPath {
|
||||||
|
public String path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package cn.nopj.chaos_api.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VideoTaskPayload implements Serializable {
|
||||||
|
private String sourceFilePath;
|
||||||
|
private String uploadId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package cn.nopj.chaos_api.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface VideoFileUploadService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化上传
|
||||||
|
* @param fileName 文件名
|
||||||
|
* @param totalSize 文件大小
|
||||||
|
* @return 上传信息
|
||||||
|
*/
|
||||||
|
Map<String, Object> initUpload(String fileName, long totalSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传分片
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
* @param chunkIndex 分片索引
|
||||||
|
* @param bytes 分片内容
|
||||||
|
* @throws IOException IO 异常
|
||||||
|
*/
|
||||||
|
void uploadChunk(String uploadId, int chunkIndex, byte[] bytes) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并并处理上传的文件
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
* @param fileName 文件名
|
||||||
|
* @throws IOException IO 异常
|
||||||
|
*/
|
||||||
|
void mergeAndProcess(String uploadId, String fileName) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将处理结果推送到MQ
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
*/
|
||||||
|
void pushResultToMQ(String filePath,String uploadId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将处理结果推送到MQ
|
||||||
|
* @param filePath 文件路径
|
||||||
|
*/
|
||||||
|
void pushResultToMQ(String filePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cn.nopj.chaos_api.service;
|
||||||
|
|
||||||
|
import cn.nopj.chaos_api.dto.VideoTaskPayload;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface VideoProcessingService {
|
||||||
|
/**
|
||||||
|
* 监听视频处理任务
|
||||||
|
*
|
||||||
|
* @param payload 视频处理任务负载
|
||||||
|
*/
|
||||||
|
void listenForVideoTasks(VideoTaskPayload payload);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理视频
|
||||||
|
*
|
||||||
|
* @param sourceFilePath 源视频文件路径
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
*/
|
||||||
|
void processVideo(String sourceFilePath, String uploadId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -36,6 +36,19 @@
|
|||||||
<artifactId>chaos_api_common</artifactId>
|
<artifactId>chaos_api_common</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-web</artifactId>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package cn.nopj.chaos_api.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class AppConfig {
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package cn.nopj.chaos_api.config;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONReader;
|
||||||
|
import org.springframework.amqp.core.Message;
|
||||||
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
import org.springframework.util.ClassUtils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义使用 Fastjson2 的消息转换器
|
||||||
|
*/
|
||||||
|
public class Fastjson2MessageConverter implements MessageConverter {
|
||||||
|
|
||||||
|
public static final String DEFAULT_CHARSET = StandardCharsets.UTF_8.name();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将Java对象转换为AMQP消息
|
||||||
|
* 在这个方法中,我们将对象序列化为JSON字节数组,并在消息头中添加类型信息。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Message toMessage(Object object, MessageProperties messageProperties) throws MessageConversionException {
|
||||||
|
if (messageProperties == null) {
|
||||||
|
messageProperties = new MessageProperties();
|
||||||
|
}
|
||||||
|
// 设置内容类型为JSON
|
||||||
|
messageProperties.setContentType(MessageProperties.CONTENT_TYPE_JSON);
|
||||||
|
messageProperties.setContentEncoding(DEFAULT_CHARSET);
|
||||||
|
|
||||||
|
// 【核心】在消息头中添加完整的类名作为类型标识
|
||||||
|
messageProperties.setHeader("__TypeId__", object.getClass().getName());
|
||||||
|
|
||||||
|
byte[] bytes = JSON.toJSONBytes(object);
|
||||||
|
messageProperties.setContentLength(bytes.length);
|
||||||
|
|
||||||
|
return new Message(bytes, messageProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将AMQP消息转换为Java对象
|
||||||
|
* 在这个方法中,我们从消息头中获取类型信息,然后用Fastjson2将消息体反序列化为指定类型的对象。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object fromMessage(Message message) throws MessageConversionException {
|
||||||
|
MessageProperties properties = message.getMessageProperties();
|
||||||
|
String typeId = properties.getHeader("__TypeId__");
|
||||||
|
|
||||||
|
if (typeId == null) {
|
||||||
|
throw new MessageConversionException("无法转换消息:未找到 __TypeId__ 消息头");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 根据类型标识找到对应的类
|
||||||
|
Class<?> targetClass = ClassUtils.forName(typeId, ClassUtils.getDefaultClassLoader());
|
||||||
|
// 使用Fastjson2进行反序列化
|
||||||
|
return JSON.parseObject(message.getBody(), targetClass, JSONReader.Feature.SupportSmartMatch);
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
throw new MessageConversionException("无法解析类型 " + typeId, e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new MessageConversionException("使用Fastjson2转换消息失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package cn.nopj.chaos_api.config;
|
||||||
|
|
||||||
|
import org.springframework.amqp.core.Binding;
|
||||||
|
import org.springframework.amqp.core.BindingBuilder;
|
||||||
|
import org.springframework.amqp.core.DirectExchange;
|
||||||
|
import org.springframework.amqp.core.Queue;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RabbitMQConfig {
|
||||||
|
public static final String QUEUE_NAME = "video.processing.queue";
|
||||||
|
public static final String EXCHANGE_NAME = "video.direct.exchange";
|
||||||
|
public static final String ROUTING_KEY = "video.processing.key";
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue videoQueue() {
|
||||||
|
// durable: true, 队列持久化
|
||||||
|
return new Queue(QUEUE_NAME, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DirectExchange videoExchange() {
|
||||||
|
return new DirectExchange(EXCHANGE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding binding(Queue queue, DirectExchange exchange) {
|
||||||
|
return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MessageConverter jsonMessageConverter() {
|
||||||
|
return new Fastjson2MessageConverter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package cn.nopj.chaos_api.service.impl;
|
||||||
|
|
||||||
|
import cn.nopj.chaos_api.config.RabbitMQConfig;
|
||||||
|
import cn.nopj.chaos_api.dto.VideoTaskPayload;
|
||||||
|
import cn.nopj.chaos_api.service.VideoFileUploadService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class VideoFileUploadServiceImpl implements VideoFileUploadService {
|
||||||
|
|
||||||
|
@Value("${file.upload.temp-dir}")
|
||||||
|
private String tempDir;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RabbitTemplate rabbitTemplate; // 注入RabbitMQ模板
|
||||||
|
|
||||||
|
private static final long CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
public Map<String, Object> initUpload(String fileName, long totalSize) {
|
||||||
|
String uploadId = UUID.randomUUID().toString();
|
||||||
|
Path uploadDir = Paths.get(tempDir, uploadId);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(uploadDir);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("无法创建临时上传目录", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of("uploadId", uploadId, "chunkSize", CHUNK_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void uploadChunk(String uploadId, int chunkIndex, byte[] bytes) throws IOException {
|
||||||
|
Path chunkPath = Paths.get(tempDir, uploadId, String.valueOf(chunkIndex));
|
||||||
|
Files.write(chunkPath, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void mergeAndProcess(String uploadId, String fileName) throws IOException {
|
||||||
|
Path uploadDir = Paths.get(tempDir, uploadId);
|
||||||
|
Path mergedFilePath = Paths.get(tempDir, fileName);
|
||||||
|
|
||||||
|
// 合并文件
|
||||||
|
try (var destChannel = Files.newByteChannel(mergedFilePath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
|
||||||
|
for (int i = 0; ; i++) {
|
||||||
|
Path chunkPath = uploadDir.resolve(String.valueOf(i));
|
||||||
|
if (!Files.exists(chunkPath)) break;
|
||||||
|
try (FileInputStream fis = new FileInputStream(chunkPath.toFile());
|
||||||
|
FileChannel sourceChannel = fis.getChannel()) {
|
||||||
|
sourceChannel.transferTo(0, sourceChannel.size(), destChannel);
|
||||||
|
}
|
||||||
|
Files.delete(chunkPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Files.delete(uploadDir);
|
||||||
|
|
||||||
|
// 发送消息到RabbitMQ,而不是直接调用Service
|
||||||
|
pushResultToMQ(mergedFilePath.toString(), uploadId);
|
||||||
|
|
||||||
|
}
|
||||||
|
public void pushResultToMQ(String filePath,String uploadId) {
|
||||||
|
VideoTaskPayload payload = new VideoTaskPayload(filePath, uploadId);
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.ROUTING_KEY,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
log.info("已发送视频处理结果到消息队列: {}" , payload);
|
||||||
|
}
|
||||||
|
public void pushResultToMQ(String filePath) {
|
||||||
|
VideoTaskPayload payload = new VideoTaskPayload(filePath, UUID.randomUUID().toString());
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.ROUTING_KEY,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
log.info("已发送视频处理结果到消息队列: {}" , payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package cn.nopj.chaos_api.service.impl;
|
||||||
|
|
||||||
|
import cn.nopj.chaos_api.config.RabbitMQConfig;
|
||||||
|
import cn.nopj.chaos_api.config.AppConfig;
|
||||||
|
import cn.nopj.chaos_api.dto.VideoTaskPayload;
|
||||||
|
import cn.nopj.chaos_api.service.VideoProcessingService;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class VideoProcessingServiceImpl implements VideoProcessingService {
|
||||||
|
|
||||||
|
@Value("${file.upload.ffmpeg-path}")
|
||||||
|
private String ffmpegPath;
|
||||||
|
@Value("${file.upload.temp-dir}")
|
||||||
|
private String tempDir;
|
||||||
|
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AppConfig restTemplate;
|
||||||
|
|
||||||
|
@RabbitListener(queues = RabbitMQConfig.QUEUE_NAME)
|
||||||
|
public void listenForVideoTasks(VideoTaskPayload payload) {
|
||||||
|
System.out.println("收到视频处理任务: " + payload);
|
||||||
|
processVideo(payload.getSourceFilePath(), payload.getUploadId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void processVideo(String sourceFilePath, String uploadId) {
|
||||||
|
Path sourcePath = Paths.get(sourceFilePath);
|
||||||
|
String tempOutputDirName = "hls_temp_" + uploadId;
|
||||||
|
Path tempOutputDirPath = Paths.get(tempDir, tempOutputDirName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 创建临时HLS输出目录
|
||||||
|
Files.createDirectories(tempOutputDirPath);
|
||||||
|
String localM3u8Path = tempOutputDirPath.resolve("playlist.m3u8").toString();
|
||||||
|
|
||||||
|
|
||||||
|
String segmentFilename = tempOutputDirPath.resolve("segment%05d.jpg").toString();
|
||||||
|
|
||||||
|
// 2. 执行FFmpeg命令进行切片
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(
|
||||||
|
ffmpegPath, "-i", sourceFilePath, "-c:v", "libx264", "-c:a", "aac",
|
||||||
|
"-hls_time", "10", "-hls_list_size", "0", "-f", "hls", "-hls_segment_filename", segmentFilename, localM3u8Path
|
||||||
|
);
|
||||||
|
// ... (FFmpeg执行逻辑不变) ...
|
||||||
|
processBuilder.redirectErrorStream(true);
|
||||||
|
Process process = processBuilder.start();
|
||||||
|
try (var reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line; while ((line = reader.readLine()) != null) { System.out.println("FFmpeg: " + line); }
|
||||||
|
}
|
||||||
|
if (process.waitFor() != 0) { throw new RuntimeException("FFmpeg切片失败"); }
|
||||||
|
|
||||||
|
// 3. 【核心】上传所有.ts文件到外部接口,并收集返回的URL
|
||||||
|
Map<String, String> segmentUrlMap = new HashMap<>();
|
||||||
|
|
||||||
|
File[] segmentFiles = tempOutputDirPath.toFile().listFiles((dir, name) -> name.endsWith(".jpg"));
|
||||||
|
if (segmentFiles == null) { throw new RuntimeException("找不到生成的.jpg切片文件"); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (File segmentFile : segmentFiles) {
|
||||||
|
String returnedUrl = uploadFileToExternalServer(segmentFile);
|
||||||
|
if (returnedUrl == null) {
|
||||||
|
throw new RuntimeException("上传文件 " + segmentFile.getName() + " 失败");
|
||||||
|
}
|
||||||
|
segmentUrlMap.put(segmentFile.getName(), returnedUrl);
|
||||||
|
log.info("上传 {} 成功, 地址: {}", segmentFile.getName(), returnedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 【核心】根据返回的URL生成新的m3u8内容
|
||||||
|
String finalM3u8Content = createFinalM3u8Content(localM3u8Path, segmentUrlMap);
|
||||||
|
|
||||||
|
log.info("m3u8:{}",finalM3u8Content);
|
||||||
|
// 5. 【核心】将最终的m3u8内容也上传到外部接口
|
||||||
|
Path finalM3u8File = tempOutputDirPath.resolve("final_playlist.m3u8");
|
||||||
|
Files.writeString(finalM3u8File, finalM3u8Content);
|
||||||
|
String finalM3u8Url = uploadFileToExternalServer(finalM3u8File.toFile());
|
||||||
|
|
||||||
|
log.info("视频处理完成!最终M3U8访问地址:{}",finalM3u8Url);
|
||||||
|
// 在这里,你可以将 finalM3u8Url 保存到数据库
|
||||||
|
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
System.err.println("视频处理失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
// 6. 清理所有本地临时文件
|
||||||
|
try {
|
||||||
|
if (Files.exists(sourcePath)) { Files.delete(sourcePath); }
|
||||||
|
if (Files.exists(tempOutputDirPath)) {
|
||||||
|
Files.walk(tempOutputDirPath)
|
||||||
|
.sorted(java.util.Comparator.reverseOrder())
|
||||||
|
.map(Path::toFile)
|
||||||
|
.forEach(File::delete);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用外部接口上传单个文件
|
||||||
|
* @param file 要上传的文件
|
||||||
|
* @return 外部接口返回的文件URL
|
||||||
|
*/
|
||||||
|
private String uploadFileToExternalServer(File file) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
headers.add("x-auth-token","47880955-1882-44ec-a250-a97a8f31a4eb");
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", new FileSystemResource(file)); // "file"是常见的表单字段名,请根据您的接口修改
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String requestUrl = "https://pinyp.vspjc.com:59789/melody/api/v1/oss/upload";
|
||||||
|
|
||||||
|
ResponseEntity<String> entity = restTemplate.restTemplate().postForEntity(requestUrl, requestEntity, String.class);
|
||||||
|
if (entity.getStatusCode() != HttpStatus.OK){
|
||||||
|
log.error("请求失败: {}", entity);
|
||||||
|
throw new RuntimeException("请求失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject jsonObject = JSONObject.parse(entity.getBody());
|
||||||
|
|
||||||
|
if (Objects.requireNonNull(jsonObject).getInteger("code") != 12200){
|
||||||
|
log.error("上传失败: {}", jsonObject);
|
||||||
|
throw new RuntimeException("上传失败,请检查接口是否失效");
|
||||||
|
}
|
||||||
|
|
||||||
|
String data = jsonObject.getString("data");
|
||||||
|
//data = https://qny-imimg.uuvuem.cn/df53/wx/20250719/8cc4f34394fb49ec90b1316ca9e26b86.jpg@,@image/jpeg@,@qiniu
|
||||||
|
String[] split = data.split("@");
|
||||||
|
String url = split[0];
|
||||||
|
log.info("上传成功: {}", url);
|
||||||
|
return url;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("上传文件失败: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 读取本地m3u8文件,并用远程URL替换ts文件名
|
||||||
|
*/
|
||||||
|
private String createFinalM3u8Content(String localM3u8Path, Map<String, String> tsUrlMap) throws IOException {
|
||||||
|
StringBuilder newContent = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new FileReader(localM3u8Path))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.endsWith(".jpg")) {
|
||||||
|
String remoteUrl = tsUrlMap.get(line.trim());
|
||||||
|
if (remoteUrl != null) {
|
||||||
|
newContent.append(remoteUrl).append("\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newContent.append(line).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newContent.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,11 @@
|
|||||||
<groupId>ch.qos.logback</groupId>
|
<groupId>ch.qos.logback</groupId>
|
||||||
<artifactId>logback-classic</artifactId>
|
<artifactId>logback-classic</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
<version>2.7.0</version>
|
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<goals>
|
<goals>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package cn.nopj.chaos_api.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/**") // 匹配所有路径
|
||||||
|
.allowedOrigins(
|
||||||
|
"http://localhost:63342", // 你的前端运行的地址
|
||||||
|
"http://localhost:8080", // 其他可能的前端地址
|
||||||
|
"http://127.0.0.1:5500", // 另一个常见的本地开发地址
|
||||||
|
"null"
|
||||||
|
) // 允许的来源
|
||||||
|
// 请根据你的前端实际部署地址修改或增加这里的源
|
||||||
|
// 在开发环境可以加 "null" 来处理从文件系统打开的页面
|
||||||
|
// 生产环境请务必替换为明确的域名或IP,避免使用"*"或"null"
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的方法
|
||||||
|
.allowedHeaders("*") // 允许所有请求头
|
||||||
|
.allowCredentials(true) // 允许发送认证信息(如 Cookies, HTTP 认证及客户端 SSL 证书)
|
||||||
|
.maxAge(3600); // 预检请求的缓存时间(秒)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package cn.nopj.chaos_api.config.sec;
|
package cn.nopj.chaos_api.config;
|
||||||
|
|
||||||
import cn.nopj.chaos_api.config.jwt.JwtAuthenticationTokenFilter;
|
import cn.nopj.chaos_api.config.jwt.JwtAuthenticationTokenFilter;
|
||||||
|
import cn.nopj.chaos_api.config.sec.RestAuthenticationEntryPoint;
|
||||||
|
import cn.nopj.chaos_api.config.sec.RestfulAccessDeniedHandler;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
@@ -13,9 +16,16 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
||||||
@@ -51,9 +61,30 @@ public class SecurityConfig {
|
|||||||
.exceptionHandling(e -> e
|
.exceptionHandling(e -> e
|
||||||
.authenticationEntryPoint(restAuthenticationEntryPoint)
|
.authenticationEntryPoint(restAuthenticationEntryPoint)
|
||||||
.accessDeniedHandler(restfulAccessDeniedHandler))
|
.accessDeniedHandler(restfulAccessDeniedHandler))
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
;
|
;
|
||||||
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
|
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList(
|
||||||
|
"http://localhost:63342", // 你的前端运行的地址
|
||||||
|
"http://localhost:8080", // 其他可能的前端地址
|
||||||
|
"http://127.0.0.1:5500", // 另一个常见的本地开发地址
|
||||||
|
"null" // 如果是从文件系统直接打开HTML文件,Origin 会是 "null"。仅用于开发!
|
||||||
|
));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L); // 注意这里是 Long 类型
|
||||||
|
source.registerCorsConfiguration("/**", configuration); // 对所有路径生效
|
||||||
|
return source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import jakarta.servlet.FilterChain;
|
|||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -21,6 +22,7 @@ import java.io.IOException;
|
|||||||
* JWT 登录授权过滤器
|
* JWT 登录授权过滤器
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@Slf4j
|
||||||
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -36,10 +38,15 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
|||||||
protected void doFilterInternal(HttpServletRequest request,
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
FilterChain chain) throws ServletException, IOException {
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
log.info("JWT 登录授权过滤器");
|
||||||
|
|
||||||
String authHeader = request.getHeader(this.tokenHeader);
|
String authHeader = request.getHeader(this.tokenHeader);
|
||||||
|
log.info("authHeader: {}", authHeader);
|
||||||
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
|
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
|
||||||
String authToken = authHeader.substring(this.tokenHead.length());
|
String authToken = authHeader.substring(this.tokenHead.length()+1);
|
||||||
|
log.info("authToken={}", authToken);
|
||||||
String username = jwtTokenUtil.getUsernameFromToken(authToken);
|
String username = jwtTokenUtil.getUsernameFromToken(authToken);
|
||||||
|
log.info("username={}", username);
|
||||||
// 如果 Token 中有用户名但上下文中没有,说明是首次登录
|
// 如果 Token 中有用户名但上下文中没有,说明是首次登录
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
|
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public class AuthController {
|
|||||||
@Value("${jwt.tokenHead}")
|
@Value("${jwt.tokenHead}")
|
||||||
private String tokenHead;
|
private String tokenHead;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册
|
||||||
|
*
|
||||||
|
* @param registerRequest 注册信息
|
||||||
|
* @return 注册结果
|
||||||
|
*/
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ApiResult<?> register(@RequestBody RegisterRequest registerRequest) {
|
public ApiResult<?> register(@RequestBody RegisterRequest registerRequest) {
|
||||||
if (authService.register(registerRequest) != null) {
|
if (authService.register(registerRequest) != null) {
|
||||||
@@ -30,7 +36,12 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
return ApiResult.failed("用户名已存在");
|
return ApiResult.failed("用户名已存在");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*
|
||||||
|
* @param loginRequest 登录信息
|
||||||
|
* @return 登录结果
|
||||||
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResult<?> login(@RequestBody LoginRequest loginRequest) {
|
public ApiResult<?> login(@RequestBody LoginRequest loginRequest) {
|
||||||
String token = authService.login(loginRequest.getUsername(), loginRequest.getPassword());
|
String token = authService.login(loginRequest.getUsername(), loginRequest.getPassword());
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package cn.nopj.chaos_api.controller;
|
||||||
|
|
||||||
|
|
||||||
|
import cn.nopj.chaos_api.dto.ProcessVideoPath;
|
||||||
|
import cn.nopj.chaos_api.model.ApiResult;
|
||||||
|
import cn.nopj.chaos_api.service.VideoFileUploadService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/video")
|
||||||
|
@Slf4j
|
||||||
|
public class VideoController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoFileUploadService videoFileUploadService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化分片上传任务
|
||||||
|
* @param fileInfo 包含文件名(fileName)和总大小(totalSize)
|
||||||
|
* @return 返回 uploadId 和每个分片的大小
|
||||||
|
*/
|
||||||
|
@PostMapping("/init")
|
||||||
|
public ApiResult<?> initUpload(@RequestBody Map<String, Object> fileInfo) {
|
||||||
|
log.info("初始化上传任务: {}", fileInfo);
|
||||||
|
String fileName = fileInfo.get("fileName").toString();
|
||||||
|
long totalSize = Long.parseLong(fileInfo.get("totalSize").toString());
|
||||||
|
return ApiResult.success(videoFileUploadService.initUpload(fileName, totalSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传分片
|
||||||
|
* @param chunk 分片文件
|
||||||
|
* @param uploadId 上传任务ID
|
||||||
|
* @param chunkIndex 分片序号 (从0开始)
|
||||||
|
*/
|
||||||
|
@PostMapping("/chunk")
|
||||||
|
|
||||||
|
public ApiResult<?> uploadChunk(@RequestParam("chunk") MultipartFile chunk,
|
||||||
|
@RequestParam("uploadId") String uploadId,
|
||||||
|
@RequestParam("chunkIndex") int chunkIndex) throws IOException {
|
||||||
|
videoFileUploadService.uploadChunk(uploadId, chunkIndex, chunk.getBytes());
|
||||||
|
return ApiResult.success("分片 " + chunkIndex + " 上传成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并文件并触发异步处理
|
||||||
|
* @param params 包含 uploadId 和 fileName
|
||||||
|
*/
|
||||||
|
@PostMapping("/merge")
|
||||||
|
public ApiResult<?> mergeAndProcess(@RequestBody Map<String, String> params) throws IOException {
|
||||||
|
String uploadId = params.get("uploadId");
|
||||||
|
String fileName = params.get("fileName");
|
||||||
|
videoFileUploadService.mergeAndProcess(uploadId, fileName);
|
||||||
|
return ApiResult.success("文件上传成功,已加入后台处理队列。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理视频
|
||||||
|
* @param processVideoPath 待处理的视频路径
|
||||||
|
*/
|
||||||
|
@PostMapping("/process")
|
||||||
|
public ApiResult<?> processVideo(@RequestBody ProcessVideoPath processVideoPath) throws IOException{
|
||||||
|
videoFileUploadService.pushResultToMQ(processVideoPath.getPath());
|
||||||
|
return ApiResult.success("视频处理任务已加入队列");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
|
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
|
||||||
userMapper.insert(user);
|
userMapper.insert(user);
|
||||||
// 你可以在这里为新用户分配默认角色
|
// 你可以在这里为新用户分配默认角色
|
||||||
|
userMapper.insertUserRole(user.getId(), 2);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.auth0.jwt.JWTVerifier;
|
|||||||
import com.auth0.jwt.algorithms.Algorithm;
|
import com.auth0.jwt.algorithms.Algorithm;
|
||||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -13,7 +14,9 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
|
||||||
public class JwtTokenUtil {
|
public class JwtTokenUtil {
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +41,9 @@ public class JwtTokenUtil {
|
|||||||
*/
|
*/
|
||||||
public String getUsernameFromToken(String token) {
|
public String getUsernameFromToken(String token) {
|
||||||
try {
|
try {
|
||||||
|
log.info("token:{};验证状态:{}",token,verifier.verify( token));
|
||||||
DecodedJWT jwt = verifier.verify(token);
|
DecodedJWT jwt = verifier.verify(token);
|
||||||
|
log.info("从 Token 中获取用户名:{}", jwt.getSubject());
|
||||||
return jwt.getSubject();
|
return jwt.getSubject();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ spring:
|
|||||||
min-idle: 5
|
min-idle: 5
|
||||||
max-active: 20
|
max-active: 20
|
||||||
max-wait: 60000
|
max-wait: 60000
|
||||||
|
rabbitmq:
|
||||||
|
host: 10.91.3.24
|
||||||
|
port: 5672
|
||||||
|
username: chaos
|
||||||
|
password: zx123456..
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 100GB
|
||||||
|
max-request-size: 100GB
|
||||||
|
|
||||||
|
|
||||||
mybatis-plus:
|
mybatis-plus:
|
||||||
mapper-locations: classpath*:/mapper/**/*.xml
|
mapper-locations: classpath*:/mapper/**/*.xml
|
||||||
@@ -26,8 +36,17 @@ mybatis-plus:
|
|||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
tokenHeader: Authorization
|
tokenHeader: Authorization
|
||||||
tokenHead: Chaos
|
tokenHead: Chaos
|
||||||
secret: zHANgcHao@1995!20250506
|
secret: zHANgcHao@1995!20250506
|
||||||
expiration: 604800
|
expiration: 604800
|
||||||
|
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
temp-dir: ./chaos/upload
|
||||||
|
ffmpeg-path: C:\Users\Chaos\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-7.1.1-full_build\bin\ffmpeg.exe
|
||||||
12
pom.xml
12
pom.xml
@@ -119,7 +119,17 @@
|
|||||||
<artifactId>logback-classic</artifactId>
|
<artifactId>logback-classic</artifactId>
|
||||||
<version>1.5.18</version>
|
<version>1.5.18</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- https://mvnrepository.com/artifact/org.springframework/spring-web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-web</artifactId>
|
||||||
|
<version>6.2.9</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
</project>
|
</project>
|
||||||
218
upload.html
Normal file
218
upload.html
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<!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>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
#fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.file-label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border: 2px dashed #ced4da;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s, border-color 0.3s;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.file-label:hover {
|
||||||
|
background-color: #dee2e6;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
#fileName {
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
#uploadButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(45deg, #007bff, #0056b3);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
#uploadButton:disabled {
|
||||||
|
background: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
#uploadButton:not(:disabled):hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
width: 0%;
|
||||||
|
height: 24px;
|
||||||
|
background: linear-gradient(45deg, #28a745, #218838);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: width 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #212529;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h2>视频分片上传</h2>
|
||||||
|
<label for="fileInput" class="file-label">选择文件</label>
|
||||||
|
<input type="file" id="fileInput" />
|
||||||
|
<div id="fileName">未选择文件</div>
|
||||||
|
<button id="uploadButton">上传</button>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress" id="progressBar">0%</div>
|
||||||
|
</div>
|
||||||
|
<p id="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const uploadButton = document.getElementById('uploadButton');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 【重要】请将这里替换为您通过登录接口获取到的真实JWT Token
|
||||||
|
const JWT_TOKEN = "Chaos "
|
||||||
|
+ "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjaGFvcyIsImlhdCI6MTc1MjkwNjk4NCwiZXhwIjoxNzUzNTExNzg0fQ.fG8bSGqji9BfKVoSpMKy5GvSQuzXNdlc7Km94nkuUyPOPVBcGLSBafzSBONxn7ECYcGhS0jmmNK-_z207zy-UA"
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:18888/api/video', // 确保这个基础URL与您的后端服务匹配
|
||||||
|
headers: { 'Authorization': JWT_TOKEN }
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (fileInput.files.length > 0) {
|
||||||
|
fileNameEl.textContent = fileInput.files[0].name;
|
||||||
|
} else {
|
||||||
|
fileNameEl.textContent = '未选择文件';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadButton.addEventListener('click', async () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
alert('请先选择文件!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadButton.disabled = true;
|
||||||
|
statusEl.textContent = '正在初始化上传...';
|
||||||
|
updateProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤 1: 初始化上传,获取 uploadId 和 chunkSize
|
||||||
|
const initResponse = await api.post('/init', {
|
||||||
|
fileName: file.name,
|
||||||
|
totalSize: file.size
|
||||||
|
});
|
||||||
|
|
||||||
|
const { uploadId, chunkSize } = initResponse.data.data;
|
||||||
|
statusEl.textContent = '初始化成功,开始上传分片...';
|
||||||
|
|
||||||
|
// 步骤 2: 分片并并发上传
|
||||||
|
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||||
|
const uploadPromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
const start = i * chunkSize;
|
||||||
|
const end = Math.min(start + chunkSize, file.size);
|
||||||
|
const chunk = file.slice(start, end);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chunk', chunk);
|
||||||
|
formData.append('uploadId', uploadId);
|
||||||
|
formData.append('chunkIndex', i);
|
||||||
|
|
||||||
|
const promise = api.post('/chunk', formData, {
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
// 这个回调是单个分片的进度,我们在这里简单更新总进度
|
||||||
|
const percentCompleted = Math.round(((i * chunkSize) + progressEvent.loaded) * 100 / file.size);
|
||||||
|
updateProgress(percentCompleted);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
uploadPromises.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有分片上传完成
|
||||||
|
await Promise.all(uploadPromises);
|
||||||
|
statusEl.textContent = '所有分片上传完毕,正在合并文件...';
|
||||||
|
|
||||||
|
// 步骤 3: 通知后端合并文件
|
||||||
|
const mergeResponse = await api.post('/merge', {
|
||||||
|
uploadId: uploadId,
|
||||||
|
fileName: file.name
|
||||||
|
});
|
||||||
|
|
||||||
|
statusEl.textContent = mergeResponse.data.message || '文件上传成功,后台处理中!';
|
||||||
|
updateProgress(100);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', error);
|
||||||
|
statusEl.textContent = '上传失败: ' + (error.response?.data?.message || error.message);
|
||||||
|
updateProgress(0, true); // 出错时重置进度条为红色
|
||||||
|
} finally {
|
||||||
|
uploadButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateProgress(percentage, isError = false) {
|
||||||
|
progressBar.style.width = percentage + '%';
|
||||||
|
progressBar.textContent = percentage + '%';
|
||||||
|
if (isError) {
|
||||||
|
progressBar.style.background = 'linear-gradient(45deg, #dc3545, #c82333)';
|
||||||
|
} else {
|
||||||
|
progressBar.style.background = 'linear-gradient(45deg, #28a745, #218838)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user