feat(chaos): 实现视频分片上传和后台处理功能- 新增视频上传相关控制器、服务接口和实现类

- 实现了视频分片上传、合并和后台处理的逻辑
- 添加了 RabbitMQ 消息队列配置和消息转换器
-优化了 JWT 认证过滤器和日志记录
- 新增了跨域配置
This commit is contained in:
Chaos
2025-07-20 07:17:30 +08:00
parent 287394e8f5
commit 3683a9d8e0
23 changed files with 916 additions and 7 deletions

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ build/
### Mac OS ###
.DS_Store
/.idea/
/chaos/

View File

@@ -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);
}

View File

@@ -0,0 +1,8 @@
package cn.nopj.chaos_api.dto;
import lombok.Data;
@Data
public class ProcessVideoPath {
public String path;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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 ();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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); // 预检请求的缓存时间(秒)
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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());

View File

@@ -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("视频处理任务已加入队列");
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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
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
View File

@@ -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
View 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>