feat(chaos): 实现视频分片上传和后台处理功能- 新增视频上传相关控制器、服务接口和实现类
- 实现了视频分片上传、合并和后台处理的逻辑 - 添加了 RabbitMQ 消息队列配置和消息转换器 -优化了 JWT 认证过滤器和日志记录 - 新增了跨域配置
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ build/
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.idea/
|
||||
/chaos/
|
||||
|
||||
@@ -29,4 +29,12 @@ public interface UserMapper extends BaseMapper<User> {
|
||||
WHERE u.username = #{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>
|
||||
<version>${project.version}</version>
|
||||
</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>
|
||||
</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>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
@@ -71,7 +76,7 @@
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>2.7.0</version>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<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.sec.RestAuthenticationEntryPoint;
|
||||
import cn.nopj.chaos_api.config.sec.RestfulAccessDeniedHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
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.configuration.EnableWebSecurity;
|
||||
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.web.SecurityFilterChain;
|
||||
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
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
||||
@@ -51,9 +61,30 @@ public class SecurityConfig {
|
||||
.exceptionHandling(e -> e
|
||||
.authenticationEntryPoint(restAuthenticationEntryPoint)
|
||||
.accessDeniedHandler(restfulAccessDeniedHandler))
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
;
|
||||
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
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.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -21,6 +22,7 @@ import java.io.IOException;
|
||||
* JWT 登录授权过滤器
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired
|
||||
@@ -36,10 +38,15 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
log.info("JWT 登录授权过滤器");
|
||||
|
||||
String authHeader = request.getHeader(this.tokenHeader);
|
||||
log.info("authHeader: {}", authHeader);
|
||||
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);
|
||||
log.info("username={}", username);
|
||||
// 如果 Token 中有用户名但上下文中没有,说明是首次登录
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
|
||||
|
||||
@@ -23,6 +23,12 @@ public class AuthController {
|
||||
@Value("${jwt.tokenHead}")
|
||||
private String tokenHead;
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*
|
||||
* @param registerRequest 注册信息
|
||||
* @return 注册结果
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ApiResult<?> register(@RequestBody RegisterRequest registerRequest) {
|
||||
if (authService.register(registerRequest) != null) {
|
||||
@@ -30,7 +36,12 @@ public class AuthController {
|
||||
}
|
||||
return ApiResult.failed("用户名已存在");
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*
|
||||
* @param loginRequest 登录信息
|
||||
* @return 登录结果
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResult<?> login(@RequestBody LoginRequest loginRequest) {
|
||||
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()));
|
||||
userMapper.insert(user);
|
||||
// 你可以在这里为新用户分配默认角色
|
||||
userMapper.insertUserRole(user.getId(), 2);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -13,7 +14,9 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import java.util.Date;
|
||||
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
public class JwtTokenUtil {
|
||||
|
||||
|
||||
@@ -38,7 +41,9 @@ public class JwtTokenUtil {
|
||||
*/
|
||||
public String getUsernameFromToken(String token) {
|
||||
try {
|
||||
log.info("token:{};验证状态:{}",token,verifier.verify( token));
|
||||
DecodedJWT jwt = verifier.verify(token);
|
||||
log.info("从 Token 中获取用户名:{}", jwt.getSubject());
|
||||
return jwt.getSubject();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
|
||||
@@ -15,6 +15,16 @@ spring:
|
||||
min-idle: 5
|
||||
max-active: 20
|
||||
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:
|
||||
mapper-locations: classpath*:/mapper/**/*.xml
|
||||
@@ -26,8 +36,17 @@ mybatis-plus:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
|
||||
|
||||
|
||||
jwt:
|
||||
tokenHeader: Authorization
|
||||
tokenHead: Chaos
|
||||
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>
|
||||
<version>1.5.18</version>
|
||||
</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>
|
||||
</dependencyManagement>
|
||||
</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