Backend update on Torrent upload with unit test complete
Change-Id: Ie8a90c82e414df32d079524d3ff3035b823b69c3
diff --git a/backend/demo/Dockerfile b/backend/demo/Dockerfile
index 5d92360..3b73f3c 100644
--- a/backend/demo/Dockerfile
+++ b/backend/demo/Dockerfile
@@ -1,25 +1,66 @@
-# 构建阶段:使用 Maven 构建项目
+# 阶段 1: builder - 构建应用 JAR 包 (跳过测试)
+# 使用包含 Maven 和 JDK 的基础镜像
FROM maven:3.9.9-eclipse-temurin-24-alpine AS builder
+# 设置工作目录
WORKDIR /app
-# 复制项目文件(忽略 target 目录)
+# 复制 pom.xml 文件,以便 Maven 可以下载依赖
+COPY pom.xml .
+
+# 复制项目源代码,包括 main 和 test
+# 这一步是为了让 Maven 在构建和下载依赖时能够解析所有模块
+COPY src ./src
+
+# 执行 Maven clean 和 dependency:go-offline
+# clean: 清理 target 目录
+# dependency:go-offline: 下载所有项目依赖到本地 Maven 仓库,供后续阶段使用
+# -B: 非交互模式
+# 这一步不执行实际的编译和测试,主要用于缓存依赖
+RUN mvn clean dependency:go-offline -B
+
+# 执行 Maven package 构建项目,生成 JAR 包
+# -DskipTests: 跳过单元和集成测试
+RUN mvn package -DskipTests
+
+
+# 阶段 2: tester - 专用于运行和调试测试的运行时环境
+# 基于与 builder 阶段相同的 Maven/JDK 基础镜像,确保测试所需的环境一致
+FROM maven:3.9.9-eclipse-temurin-24-alpine AS tester
+
+# 设置工作目录
+WORKDIR /app
+
+# 复制 pom.xml 和完整的 src 目录 (包含测试代码)
COPY pom.xml .
COPY src ./src
-# 构建应用,跳过测试
-RUN mvn clean package -DskipTests
+# 从 builder 阶段复制已缓存的 Maven 本地仓库
+# 这样在 tester 阶段运行 Maven 时无需重新下载依赖
+COPY --from=builder /root/.m2 /root/.m2
-# 运行阶段:使用较小的 JDK 镜像运行应用
-FROM openjdk:17-jdk-slim-buster
+# 暴露调试端口 (可选,如果您需要远程调试测试过程)
+# 您可以在 docker compose 中映射这个端口到主机
+EXPOSE 5005
+# 设置默认命令,使容器启动后保持运行
+# 这样您可以方便地使用 docker exec 进入容器手动执行测试和调试命令
+# 如果您希望容器启动后自动运行测试,可以修改此命令为 ["mvn", "test"]
+CMD ["tail", "-f", "/dev/null"]
+
+
+# 阶段 3: runner - 运行最终应用 JAR 包的精简运行时环境
+# 使用轻量级的 OpenJDK JRE 镜像
+FROM openjdk:17-jdk-slim-buster AS runner
+
+# 设置工作目录
WORKDIR /app
-# 从构建阶段复制打好的 jar 包
+# 从 builder 阶段复制构建好的应用 JAR 包
COPY --from=builder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar
-# 暴露 Spring Boot 默认端口
+# 暴露 Spring Boot 应用的默认端口
EXPOSE 8080
-# 启动命令
+# 启动 Spring Boot 应用
CMD ["java", "-jar", "app.jar"]
diff --git a/backend/demo/compose.yaml b/backend/demo/compose.yaml
index 61d6b9d..4d288a2 100644
--- a/backend/demo/compose.yaml
+++ b/backend/demo/compose.yaml
@@ -1,29 +1,74 @@
services:
+ # 数据库服务
mysql:
- image: mysql:latest
+ image: mysql:8.0 # 建议使用具体的版本号,而不是 latest
+ container_name: mysql_db # 给容器一个易于识别的名称
environment:
- - MYSQL_DATABASE=mydatabase
- - MYSQL_USER=myuser
- - MYSQL_PASSWORD=secret
- - MYSQL_ROOT_PASSWORD=verysecret
+ MYSQL_DATABASE: mydatabase
+ MYSQL_USER: myuser
+ MYSQL_PASSWORD: secret
+ MYSQL_ROOT_PASSWORD: verysecret
ports:
- - "3306:3306"
+ - "3306:3306" # 映射数据库端口,方便本地工具连接(可选,仅测试容器连接DB时可省略)
volumes:
- - mysql-data:/var/lib/mysql
+ - mysql-data:/var/lib/mysql # 持久化数据库数据
+ # 添加健康检查,确保数据库服务真正可用
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]
+ interval: 10s # 每 10 秒检查一次
+ timeout: 5s # 检查超时时间
+ retries: 5 # 重试次数
+ start_period: 30s # 在此期间不计入重试失败次数,给数据库启动时间
+ # 应用服务(使用 Dockerfile 的 runner 阶段)
app:
build:
- context: .
- dockerfile: Dockerfile
+ context: . # Dockerfile 所在的上下文路径
+ dockerfile: Dockerfile # Dockerfile 文件名
+ target: runner # 指定构建 Dockerfile 中的 runner 阶段
+ container_name: spring_app
ports:
- - "8080:8080"
+ - "8080:8080" # 映射应用端口
environment:
- SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mydatabase?serverTimezone=Asia/Shanghai
+ # 应用连接数据库的配置,使用数据库服务的名称作为主机名
+ SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mydatabase?serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=TRUE&useSSL=FALSE&allowPublicKeyRetrieval=TRUE
SPRING_DATASOURCE_USERNAME: myuser
SPRING_DATASOURCE_PASSWORD: secret
+ # 其他 Spring Boot 配置...
depends_on:
- - mysql
+ mysql:
+ condition: service_healthy # 确保数据库服务处于健康状态才启动应用
+ # 测试服务(使用 Dockerfile 的 tester 阶段)
+ # 这个服务用于运行和调试需要连接数据库的测试
+ tester:
+ build:
+ context: . # Dockerfile 所在的上下文路径
+ dockerfile: Dockerfile # Dockerfile 文件名
+ target: tester # 指定构建 Dockerfile 中的 tester 阶段
+ container_name: tester
+ # 暴露调试端口,映射到主机端口,用于远程调试测试过程
+ ports:
+ - "5005:5005"
+ # 依赖于数据库服务,并等待其健康检查通过
+ depends_on:
+ mysql:
+ condition: service_healthy # 确保数据库服务处于健康状态才启动测试容器
+ environment:
+ # 测试环境连接数据库的配置,使用数据库服务的名称作为主机名
+ # 这些环境变量会覆盖 application-test.properties 中的相同配置
+ SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mydatabase?serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=TRUE&useSSL=FALSE
+ SPRING_DATASOURCE_USERNAME: myuser
+ SPRING_DATASOURCE_PASSWORD: secret
+ # 设置 Maven 运行时的额外 JVM 参数,用于开启远程调试
+ MAVEN_OPTS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
+ # 默认命令:保持容器运行,方便手动进入容器执行测试和调试
+ # 如果您希望容器启动后自动运行测试,可以修改此命令为 ["mvn", "test"]
+ command: tail -f /dev/null
+
+
+# 定义数据卷用于持久化数据库数据
volumes:
mysql-data:
+
diff --git a/backend/demo/db/SQLScript.sql b/backend/demo/db/SQLScript.sql
new file mode 100644
index 0000000..13977af
--- /dev/null
+++ b/backend/demo/db/SQLScript.sql
@@ -0,0 +1,43 @@
+-- 创建数据库(如果不存在)
+-- 请确保您的Docker Compose配置中的数据库名称与此处一致
+CREATE DATABASE IF NOT EXISTS mydatabase CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- 切换到目标数据库
+USE mydatabase;
+
+-- 删除表(如果已存在),以便重新创建
+DROP TABLE IF EXISTS `user`;
+
+-- 创建用户表
+CREATE TABLE `user` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键,自增长',
+ `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一,不可为空',
+ `password` VARCHAR(100) NOT NULL COMMENT '密码,存储加密后的密码,不可为空',
+ `email` VARCHAR(100) NULL DEFAULT NULL COMMENT '邮箱地址',
+ `status` INT NOT NULL DEFAULT 1 COMMENT '用户状态,例如 0:禁用, 1:启用,默认为1',
+ `score` INT NOT NULL DEFAULT 0 COMMENT '用户积分,默认为0',
+ `role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色,例如 ADMIN, USER,默认为USER',
+ `create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_username` (`username`) -- 确保用户名唯一
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
+
+-- 可选:插入一条测试用户数据
+-- 请注意:这里的密码 'password123' 是明文,您需要用您应用中配置的BCryptPasswordEncoder加密后的密码替换它
+-- 例如,使用 Spring Security 的 BCryptPasswordEncoder 对 'your_secure_password' 进行加密
+-- SELECT '$2a$10$YOUR_BCRYPT_HASH_HERE'; -- 替换这里的哈希值
+
+INSERT INTO `user` (`username`, `password`, `email`, `status`, `score`, `role`, `create_time`, `update_time`)
+VALUES (
+ 'testuser',
+ '$$2a$10$EEBnvonhdTGA0StQHdxMUeAQyxm1uvJOKkHu01Pe0B9a2vfGH3cge', -- 请用加密后的密码替换此处的占位符
+ 'test@example.com',
+ 1, -- 启用状态
+ 100, -- 初始积分
+ 'USER', -- 默认角色
+ CURRENT_TIMESTAMP,
+ CURRENT_TIMESTAMP
+);
+
+
diff --git a/backend/demo/pom.xml b/backend/demo/pom.xml
index bac660b..6eccaf5 100644
--- a/backend/demo/pom.xml
+++ b/backend/demo/pom.xml
@@ -97,7 +97,39 @@
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
+ <dependency>
+ <groupId>com.turn</groupId>
+ <artifactId>ttorrent-bencoding</artifactId>
+ <version>1.3.0-SNAPSHOT</version>
+ </dependency>
+ <!-- 通用工具模块(info_hash、文件结构等) -->
+ <dependency>
+ <groupId>com.turn</groupId>
+ <artifactId>ttorrent-common</artifactId>
+ <version>1.3.0-SNAPSHOT</version>
+ </dependency>
+
+ <!-- 网络通信支持(用于 Scrape/Tracker 通信) -->
+ <dependency>
+ <groupId>com.turn</groupId>
+ <artifactId>ttorrent-network</artifactId>
+ <version>1.0</version>
+ </dependency>
+
+ <!-- Tracker 服务(你自己部署的 announce 接口服务) -->
+ <dependency>
+ <groupId>com.turn</groupId>
+ <artifactId>ttorrent-tracker</artifactId>
+ <version>1.3.0-SNAPSHOT</version>
+ </dependency>
+
+ <!-- 客户端支持(如果你需要服务器自动发种、做种) -->
+ <dependency>
+ <groupId>com.turn</groupId>
+ <artifactId>ttorrent-client</artifactId>
+ <version>1.3.0-SNAPSHOT</version>
+ </dependency>
</dependencies>
<build>
diff --git a/backend/demo/src/main/java/com/example/demo/config/SecurityConfig.java b/backend/demo/src/main/java/com/example/demo/config/SecurityConfig.java
index bfd7b1b..b3e7f91 100644
--- a/backend/demo/src/main/java/com/example/demo/config/SecurityConfig.java
+++ b/backend/demo/src/main/java/com/example/demo/config/SecurityConfig.java
@@ -5,6 +5,8 @@
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -12,13 +14,17 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.example.demo.security.JwtAuthenticationFilter;
-import com.example.demo.security.JwtTokenUtil;
+/**
+ * Spring Security 配置类,用于定义安全策略和相关的 Bean。
+ */
@Configuration
+@EnableWebSecurity // 启用 Spring Security 的 Web 安全功能
public class SecurityConfig {
/**
- * 密码加密器,用于注册用户时对密码加密、登录时校验
+ * 密码加密器,用于注册用户时对密码加密、登录时校验。
+ * 使用 BCryptPasswordEncoder,一种安全的密码哈希算法。
*/
@Bean
public PasswordEncoder passwordEncoder() {
@@ -27,7 +33,7 @@
/**
* 将 Spring Security 的 AuthenticationManager 暴露为 Bean,
- * 方便在 AuthController 或其它地方手动调用。
+ * 方便在 AuthController 或其它地方手动调用进行认证。
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
@@ -35,17 +41,43 @@
}
/**
- * 核心安全策略:禁用 CSRF、无状态 Session、开放登录接口、其余接口需认证。
+ * 核心安全策略配置。
+ * 配置内容包括:禁用 CSRF、禁用 Session 管理(使用 JWT 无状态)、
+ * 配置请求的授权规则,并将自定义的 JWT 认证过滤器添加到过滤器链中。
+ *
+ * @param http HttpSecurity 配置对象
+ * @param jwtFilter 自定义的 JWT 认证过滤器,通过 Spring 容器注入
+ * @return 配置好的 SecurityFilterChain
+ * @throws Exception 配置过程中可能抛出的异常
*/
@Bean
-public SecurityFilterChain filterChain(HttpSecurity http,
- JwtTokenUtil tokenUtil,
- JWTProperties props) throws Exception {
- JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenUtil, props);
- http
- // 省略其他配置…
- .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
- return http.build();
-}
-}
+ public SecurityFilterChain filterChain(HttpSecurity http,
+ JwtAuthenticationFilter jwtFilter // 直接注入 JwtAuthenticationFilter Bean
+ ) throws Exception {
+ http
+ // 禁用 CSRF (跨站请求伪造) 保护,因为 JWT 是无状态的,不需要 CSRF 保护
+ .csrf(AbstractHttpConfigurer::disable)
+ // 配置 Session 管理策略为无状态,不使用 Session
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ // 配置请求的授权规则
+ .authorizeHttpRequests(auth -> auth
+ // 允许对 /auth/** 路径下的所有请求进行匿名访问 (例如登录、注册接口)
+ .requestMatchers("/auth/**").permitAll()
+ // 其他所有请求都需要进行身份认证
+ .anyRequest().authenticated()
+ )
+ // 禁用默认的表单登录功能,因为我们使用 JWT 进行认证
+ .formLogin(AbstractHttpConfigurer::disable)
+ // 禁用默认的 HTTP Basic 认证
+ .httpBasic(AbstractHttpConfigurer::disable)
+ // 将自定义的 JwtAuthenticationFilter 添加到 UsernamePasswordAuthenticationFilter 之前
+ // 确保在进行基于用户名密码的认证之前,先进行 JWT 认证
+ .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+ // 构建并返回配置好的 SecurityFilterChain
+ return http.build();
+ }
+
+ // 注意:JwtAuthenticationFilter 需要被 Spring 扫描到并作为 Bean 管理
+ // 确保 JwtAuthenticationFilter 类上有 @Component 或其他 Spring Bean 相关的注解
+}
diff --git a/backend/demo/src/main/java/com/example/demo/controller/AuthController.java b/backend/demo/src/main/java/com/example/demo/controller/AuthController.java
index 08411d7..d7dfe50 100644
--- a/backend/demo/src/main/java/com/example/demo/controller/AuthController.java
+++ b/backend/demo/src/main/java/com/example/demo/controller/AuthController.java
@@ -4,8 +4,11 @@
import java.util.List;
import java.util.Map;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager; // 导入 AuthenticationManager
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; // 导入 UsernamePasswordAuthenticationToken
+import org.springframework.security.core.Authentication; // 导入 Authentication
+import org.springframework.security.core.context.SecurityContextHolder; // 导入 SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
@@ -27,16 +30,18 @@
public class AuthController {
private final UserService userService;
- private final PasswordEncoder passwordEncoder;
+ private final PasswordEncoder passwordEncoder; // 虽然在AuthController中不再直接用于比对,但如果UserServiceImpl需要,这里保留注入
private final JwtTokenUtil jwtTokenUtil;
+ private final AuthenticationManager authenticationManager; // 注入 AuthenticationManager
- @Autowired
public AuthController(UserService userService,
PasswordEncoder passwordEncoder,
- JwtTokenUtil jwtTokenUtil) {
+ JwtTokenUtil jwtTokenUtil,
+ AuthenticationManager authenticationManager) { // 注入 AuthenticationManager
this.userService = userService;
this.passwordEncoder = passwordEncoder;
this.jwtTokenUtil = jwtTokenUtil;
+ this.authenticationManager = authenticationManager; // 初始化 AuthenticationManager
}
@PostMapping("/login")
@@ -46,12 +51,40 @@
if (bindingResult.hasErrors()) {
String errMsg = bindingResult.getFieldErrors().stream()
- .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
- .reduce((a, b) -> a + "; " + b)
- .orElse("Invalid parameters");
+ .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
+ .reduce((a, b) -> a + "; " + b)
+ .orElse("Invalid parameters");
return ResponseEntity.badRequest().body(Map.of("error", errMsg));
}
+ try {
+ // 使用 AuthenticationManager 进行认证
+ // 这会触发 UserServiceImpl 中的 loadUserByUsername 方法的调用,并比较密码
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(
+ loginRequest.getUsername(),
+ loginRequest.getPassword()
+ )
+ );
+ // 将认证信息设置到 Spring Security 的上下文中
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ // 获取认证后的用户详情(假设 User 实体实现了 UserDetails 接口)
+ User userDetails = (User) authentication.getPrincipal();
+
+ // 调用 UserService 方法生成登录响应(包括 JWT Token)
+ LoginResponseDTO response = userService.generateLoginResponse(userDetails);
+
+ return ResponseEntity.ok(response);
+
+ } catch (Exception e) {
+ // 认证失败(用户名不存在或密码错误),抛出自定义认证异常
+ // GlobalExceptionHandler 会捕获 AuthException 并返回 401
+ throw new AuthException("用户名或密码错误", e);
+ }
+
+ // 移除原有的手动查找用户和密码比对逻辑
+ /*
User user = userService.lambdaQuery()
.eq(User::getUsername, loginRequest.getUsername())
.one();
@@ -70,5 +103,6 @@
response.setRoles(List.of(user.getRole()));
return ResponseEntity.ok(response);
+ */
}
-}
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/controller/TorrentController.java b/backend/demo/src/main/java/com/example/demo/controller/TorrentController.java
new file mode 100644
index 0000000..0bd501a
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/controller/TorrentController.java
@@ -0,0 +1,130 @@
+// src/main/java/com/example/demo/controller/TorrentController.java
+package com.example.demo.controller;
+
+import java.io.IOException; // 导入 DTO
+import java.nio.file.Path; // 导入服务接口
+import java.nio.file.Paths; // 导入 ttorrent 的异常
+
+import org.springframework.beans.factory.annotation.Value; // 导入 @Value 用于读取配置
+import org.springframework.core.io.Resource; // 导入 Resource
+import org.springframework.core.io.UrlResource; // 导入 UrlResource
+import org.springframework.http.HttpHeaders; // 导入 HttpHeaders
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType; // 导入 MediaType
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.example.demo.dto.TorrentInfoDTO;
+import com.example.demo.service.TorrentService;
+import com.turn.ttorrent.bcodec.InvalidBEncodingException;
+
+/**
+ * 处理 .torrent 文件上传和下载的 RESTful 控制器。
+ */
+@RestController // 组合了 @Controller 和 @ResponseBody,表示这是一个 RESTful 控制器
+@RequestMapping("/api") // 定义所有处理方法的根路径
+public class TorrentController {
+
+ private final TorrentService torrentService;
+
+ @Value("${file.upload-dir}") // 从 application.properties 中注入文件上传目录
+ private String uploadDir;
+
+ // 通过构造函数注入 TorrentService
+ public TorrentController(TorrentService torrentService) {
+ this.torrentService = torrentService;
+ }
+
+ /**
+ * 处理 .torrent 文件的上传请求。
+ *
+ * @param file 上传的 MultipartFile 对象,通过 @RequestParam("file") 绑定。
+ * @return 包含 TorrentInfoDTO 的 ResponseEntity,表示操作结果。
+ */
+ @PostMapping("/torrents") // 映射到 /api/torrents 的 POST 请求
+ public ResponseEntity<TorrentInfoDTO> uploadTorrent(@RequestParam("file") MultipartFile file) {
+ // 1. 接收 HTTP 请求 (通过 @PostMapping 和 @RequestParam)
+
+ // 2. 校验参数
+ if (file.isEmpty()) {
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("上传文件不能为空。");
+ return new ResponseEntity<>(errorDto, HttpStatus.BAD_REQUEST);
+ }
+
+ // 校验文件类型 (可选,但推荐)
+ if (!file.getOriginalFilename().toLowerCase().endsWith(".torrent")) {
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("只允许上传 .torrent 文件。");
+ return new ResponseEntity<>(errorDto, HttpStatus.UNSUPPORTED_MEDIA_TYPE);
+ }
+
+ try {
+ // 3. 调用 Service 层处理业务逻辑
+ TorrentInfoDTO result = torrentService.handleUpload(file);
+
+ // 4. 返回 TorrentInfoDTO 给前端
+ return new ResponseEntity<>(result, HttpStatus.OK);
+ } catch (InvalidBEncodingException e) {
+ // 捕获 .torrent 文件解析错误
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("文件解析失败,请确保它是有效的 .torrent 文件: " + e.getMessage());
+ return new ResponseEntity<>(errorDto, HttpStatus.BAD_REQUEST);
+ } catch (IllegalArgumentException e) {
+ // 捕获服务层抛出的非法参数异常 (如文件为空)
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("处理文件时发生错误: " + e.getMessage());
+ return new ResponseEntity<>(errorDto, HttpStatus.BAD_REQUEST);
+ } catch (IOException e) {
+ // 捕获文件读写或保存失败的异常
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("文件上传或保存失败: " + e.getMessage());
+ return new ResponseEntity<>(errorDto, HttpStatus.INTERNAL_SERVER_ERROR);
+ } catch (Exception e) {
+ // 捕获其他未知异常
+ TorrentInfoDTO errorDto = new TorrentInfoDTO();
+ errorDto.setMessage("文件上传过程中发生未知错误: " + e.getMessage());
+ e.printStackTrace(); // 打印堆栈跟踪以便调试
+ return new ResponseEntity<>(errorDto, HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * 实现 .torrent 文件的下载功能。
+ * 根据 infoHash 找到对应的 .torrent 文件并提供下载。
+ *
+ * @param infoHash 洪流的 info hash,用于查找服务器上的文件。
+ * @param fileName 原始文件名 (用于设置下载时的文件名,实际查找文件仍依赖 infoHash)。
+ * @return 包含 .torrent 文件内容的 ResponseEntity。
+ */
+ // 修正后的 downloadTorrent 方法片段
+@GetMapping("/downloads/{infoHash}/{fileName}")
+public ResponseEntity<Resource> downloadTorrent(@PathVariable String infoHash, @PathVariable String fileName) {
+ try {
+ Path fileStorageLocation = Paths.get(uploadDir).toAbsolutePath().normalize();
+ Path filePath = fileStorageLocation.resolve(infoHash + ".torrent").normalize();
+ Resource resource = new UrlResource(filePath.toUri()); // 这一行不会抛出 MalformedURLException
+
+ if (resource.exists() && resource.isReadable()) {
+ String contentType = "application/x-bittorrent";
+ String headerValue = "attachment; filename=\"" + fileName + "\"";
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(contentType))
+ .header(HttpHeaders.CONTENT_DISPOSITION, headerValue)
+ .body(resource);
+ } else {
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ }
+ } catch (IOException e) { // 只保留 IOException
+ return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+ // MalformedURLException 相关的 catch 块已移除
+}
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/dto/TorrentInfoDTO.java b/backend/demo/src/main/java/com/example/demo/dto/TorrentInfoDTO.java
new file mode 100644
index 0000000..45cd326
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/dto/TorrentInfoDTO.java
@@ -0,0 +1,87 @@
+// src/main/java/com/example/demo/dto/TorrentInfoDTO.java
+package com.example.demo.dto;
+
+import java.io.Serializable;
+
+public class TorrentInfoDTO implements Serializable {
+ private String fileName;
+ private long fileSize;
+ private String infoHash;
+ private String magnetUri;
+ private String downloadUrl;
+ private String message; // For success or error messages
+
+ // Constructors
+ public TorrentInfoDTO() {
+ }
+
+ public TorrentInfoDTO(String fileName, long fileSize, String infoHash, String magnetUri, String downloadUrl, String message) {
+ this.fileName = fileName;
+ this.fileSize = fileSize;
+ this.infoHash = infoHash;
+ this.magnetUri = magnetUri;
+ this.downloadUrl = downloadUrl;
+ this.message = message;
+ }
+
+ // Getters and Setters
+ public String getFileName() {
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+ this.fileName = fileName;
+ }
+
+ public long getFileSize() {
+ return fileSize;
+ }
+
+ public void setFileSize(long fileSize) {
+ this.fileSize = fileSize;
+ }
+
+ public String getInfoHash() {
+ return infoHash;
+ }
+
+ public void setInfoHash(String infoHash) {
+ this.infoHash = infoHash;
+ }
+
+ public String getMagnetUri() {
+ return magnetUri;
+ }
+
+ public void setMagnetUri(String magnetUri) {
+ this.magnetUri = magnetUri;
+ }
+
+ public String getDownloadUrl() {
+ return downloadUrl;
+ }
+
+ public void setDownloadUrl(String downloadUrl) {
+ this.downloadUrl = downloadUrl;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return "TorrentInfoDTO{" +
+ "fileName='" + fileName + '\'' +
+ ", fileSize=" + fileSize +
+ ", infoHash='" + infoHash + '\'' +
+ ", magnetUri='" + magnetUri + '\'' +
+ ", downloadUrl='" + downloadUrl + '\'' +
+ ", message='" + message + '\'' +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/entity/TorrentInfo.java b/backend/demo/src/main/java/com/example/demo/entity/TorrentInfo.java
new file mode 100644
index 0000000..da39654
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/entity/TorrentInfo.java
@@ -0,0 +1,118 @@
+// src/main/java/com/example/demo/entity/TorrentInfo.java
+package com.example.demo.entity;
+
+import java.time.LocalDateTime;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+@TableName("torrent_info") // 映射到数据库中的 torrent_info 表
+public class TorrentInfo {
+
+ @TableId(value = "id", type = IdType.AUTO) // 标记为主键,并设置为数据库自增
+ private Long id;
+
+ @TableField("info_hash") // 映射到数据库中的 info_hash 列
+ private String infoHash; // 洪流的唯一标识符(信息哈希)
+
+ @TableField("file_name") // 映射到数据库中的 file_name 列
+ private String fileName; // 原始文件名
+
+ @TableField("file_size") // 映射到数据库中的 file_size 列
+ private Long fileSize; // 文件大小(字节)
+
+ @TableField("magnet_uri") // 映射到数据库中的 magnet_uri 列
+ private String magnetUri; // 生成的磁力链接
+
+ @TableField("download_url") // 映射到数据库中的 download_url 列
+ private String downloadUrl; // .torrent 文件的下载 URL
+
+ @TableField("upload_time") // 映射到数据库中的 upload_time 列
+ private LocalDateTime uploadTime; // 上传时间
+
+ // 默认构造函数,MyBatis-Plus 需要
+ public TorrentInfo() {
+ }
+
+ // 带有所有字段的构造函数(不包含 ID 和 uploadTime,因为它们是自动生成的)
+ public TorrentInfo(String infoHash, String fileName, Long fileSize, String magnetUri, String downloadUrl) {
+ this.infoHash = infoHash;
+ this.fileName = fileName;
+ this.fileSize = fileSize;
+ this.magnetUri = magnetUri;
+ this.downloadUrl = downloadUrl;
+ this.uploadTime = LocalDateTime.now(); // 设置当前上传时间
+ }
+
+ // Getters and Setters
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getInfoHash() {
+ return infoHash;
+ }
+
+ public void setInfoHash(String infoHash) {
+ this.infoHash = infoHash;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+ this.fileName = fileName;
+ }
+
+ public Long getFileSize() {
+ return fileSize;
+ }
+
+ public void setFileSize(Long fileSize) {
+ this.fileSize = fileSize;
+ }
+
+ public String getMagnetUri() {
+ return magnetUri;
+ }
+
+ public void setMagnetUri(String magnetUri) {
+ this.magnetUri = magnetUri;
+ }
+
+ public String getDownloadUrl() {
+ return downloadUrl;
+ }
+
+ public void setDownloadUrl(String downloadUrl) {
+ this.downloadUrl = downloadUrl;
+ }
+
+ public LocalDateTime getUploadTime() {
+ return uploadTime;
+ }
+
+ public void setUploadTime(LocalDateTime uploadTime) {
+ this.uploadTime = uploadTime;
+ }
+
+ @Override
+ public String toString() {
+ return "TorrentInfo{" +
+ "id=" + id +
+ ", infoHash='" + infoHash + '\'' +
+ ", fileName='" + fileName + '\'' +
+ ", fileSize=" + fileSize +
+ ", magnetUri='" + magnetUri + '\'' +
+ ", downloadUrl='" + downloadUrl + '\'' +
+ ", uploadTime=" + uploadTime +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/entity/User.java b/backend/demo/src/main/java/com/example/demo/entity/User.java
index bcbe6de..ba308af 100644
--- a/backend/demo/src/main/java/com/example/demo/entity/User.java
+++ b/backend/demo/src/main/java/com/example/demo/entity/User.java
@@ -1,7 +1,13 @@
package com.example.demo.entity;
-import java.io.Serializable;
-import java.time.LocalDateTime;
+import java.time.LocalDateTime; // Keep import if Serializable is used elsewhere, otherwise remove
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List; // 用于返回空的权限集合,如果用户没有角色/权限
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority; // 用于构建权限列表
+import org.springframework.security.core.userdetails.UserDetails;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -9,7 +15,8 @@
@TableName("user")
-public class User implements Serializable {
+// 实现UserDetails接口 (UserDetails extends Serializable, so Serializable is redundant here)
+public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
@@ -21,16 +28,18 @@
private String email;
- private Integer status;
+ private Integer status; // 假设status字段表示用户状态,例如 0:禁用, 1:启用
- private Integer score;
+ private Integer score;
- private String role;
+ private String role; // 假设role字段存储用户的角色,例如 "ADMIN", "USER"
private LocalDateTime createTime;
private LocalDateTime updateTime;
+ // --- Getters and Setters ---
+
public Long getId() {
return id;
}
@@ -39,6 +48,7 @@
this.id = id;
}
+ @Override // 实现UserDetails接口的getUsername方法
public String getUsername() {
return username;
}
@@ -47,6 +57,7 @@
this.username = username;
}
+ @Override // 实现UserDetails接口的getPassword方法
public String getPassword() {
return password;
}
@@ -102,4 +113,49 @@
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
+
+ // --- 实现 UserDetails 接口的方法 ---
+
+ @Override
+ public Collection<? extends GrantedAuthority> getAuthorities() {
+ // 根据用户的角色构建权限列表
+ List<GrantedAuthority> authorities = new ArrayList<>();
+ if (this.role != null && !this.role.isEmpty()) {
+ // Spring Security 推荐角色以 "ROLE_" 开头
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + this.role));
+ }
+ // 如果用户没有角色,返回一个空的集合
+ return authorities;
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ // 根据您的业务逻辑判断账户是否过期
+ // 例如,如果您的User实体有过期时间字段,可以在这里检查
+ // 这里简单返回true,表示账户永不过期
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ // 根据您的业务逻辑判断账户是否被锁定
+ // 例如,如果您的User实体有锁定状态字段,可以在这里检查
+ // 这里简单返回true,表示账户永不锁定
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ // 根据您的业务逻辑判断凭证(密码)是否过期
+ // 例如,如果您的User实体有密码最后修改时间字段,可以在这里检查
+ // 这里简单返回true,表示凭证永不过期
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ // 根据您的业务逻辑判断账户是否启用
+ // 假设status字段为1表示启用
+ return this.status != null && this.status == 1;
+ }
}
diff --git a/backend/demo/src/main/java/com/example/demo/mapper/TorrentInfoMapper.java b/backend/demo/src/main/java/com/example/demo/mapper/TorrentInfoMapper.java
new file mode 100644
index 0000000..3f4f245
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/mapper/TorrentInfoMapper.java
@@ -0,0 +1,13 @@
+// src/main/java/com/example/demo/mapper/TorrentInfoMapper.java
+package com.example.demo.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.example.demo.entity.TorrentInfo; // MyBatis 的 Mapper 注解
+
+@Mapper // 标记这是一个 MyBatis Mapper 接口
+public interface TorrentInfoMapper extends BaseMapper<TorrentInfo> {
+ // BaseMapper 提供了基本的 CRUD 操作,例如 insert(), selectById(), selectList(), deleteById() 等。
+ // 无需编写任何实现代码。
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/mapper/UserMapper.java b/backend/demo/src/main/java/com/example/demo/mapper/UserMapper.java
index 94ba7a8..0198c39 100644
--- a/backend/demo/src/main/java/com/example/demo/mapper/UserMapper.java
+++ b/backend/demo/src/main/java/com/example/demo/mapper/UserMapper.java
@@ -1,8 +1,9 @@
package com.example.demo.mapper;
+import org.apache.ibatis.annotations.Mapper;
+
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
-import org.apache.ibatis.annotations.Mapper;
/**
* UserMapper 接口
@@ -11,6 +12,10 @@
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
+
+ User findById(Long id);
+
+ User findByUsername(String username);
// List<User> selectByStatus(@Param("status") Integer status);
}
diff --git a/backend/demo/src/main/java/com/example/demo/result.txt b/backend/demo/src/main/java/com/example/demo/result.txt
new file mode 100644
index 0000000..bd1dd30
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/result.txt
@@ -0,0 +1,1031 @@
+--- Start File Content ---
+
+--- Content of: ./config/JWTProperties.java ---
+package com.example.demo.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * JWT 配置属性,从 application.properties 中 jwt 开头的配置加载
+ */
+@Component
+@ConfigurationProperties(prefix = "jwt")
+public class JWTProperties {
+ /**
+ * 用于签名的密钥
+ */
+ private String secret;
+
+ /**
+ * Token 过期时长,单位毫秒
+ */
+ private long expirationMs;
+
+ /**
+ * HTTP Header 中放置 JWT 的字段名
+ */
+ private String header = "Authorization";
+
+ /**
+ * Header 中 Token 的前缀
+ */
+ private String tokenPrefix = "Bearer ";
+
+ /**
+ * 签发者信息(可选)
+ */
+ private String issuer;
+
+ /**
+ * 接收者信息(可选)
+ */
+ private String audience;
+
+ // --- getters & setters ---
+
+ public String getSecret() {
+ return secret;
+ }
+
+ public void setSecret(String secret) {
+ this.secret = secret;
+ }
+
+ public long getExpirationMs() {
+ return expirationMs;
+ }
+
+ public void setExpirationMs(long expirationMs) {
+ this.expirationMs = expirationMs;
+ }
+
+ public String getHeader() {
+ return header;
+ }
+
+ public void setHeader(String header) {
+ this.header = header;
+ }
+
+ public String getTokenPrefix() {
+ return tokenPrefix;
+ }
+
+ public void setTokenPrefix(String tokenPrefix) {
+ this.tokenPrefix = tokenPrefix;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public void setIssuer(String issuer) {
+ this.issuer = issuer;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public void setAudience(String audience) {
+ this.audience = audience;
+ }
+}
+
+--- End of: ./config/JWTProperties.java ---
+
+--- Content of: ./config/SecurityConfig.java ---
+package com.example.demo.config;
+
+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.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+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 com.example.demo.security.JwtAuthenticationFilter;
+
+/**
+ * Spring Security 配置类,用于定义安全策略和相关的 Bean。
+ */
+@Configuration
+@EnableWebSecurity // 启用 Spring Security 的 Web 安全功能
+public class SecurityConfig {
+
+ /**
+ * 密码加密器,用于注册用户时对密码加密、登录时校验。
+ * 使用 BCryptPasswordEncoder,一种安全的密码哈希算法。
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ /**
+ * 将 Spring Security 的 AuthenticationManager 暴露为 Bean,
+ * 方便在 AuthController 或其它地方手动调用进行认证。
+ */
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ /**
+ * 核心安全策略配置。
+ * 配置内容包括:禁用 CSRF、禁用 Session 管理(使用 JWT 无状态)、
+ * 配置请求的授权规则,并将自定义的 JWT 认证过滤器添加到过滤器链中。
+ *
+ * @param http HttpSecurity 配置对象
+ * @param jwtFilter 自定义的 JWT 认证过滤器,通过 Spring 容器注入
+ * @return 配置好的 SecurityFilterChain
+ * @throws Exception 配置过程中可能抛出的异常
+ */
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http,
+ JwtAuthenticationFilter jwtFilter // 直接注入 JwtAuthenticationFilter Bean
+ ) throws Exception {
+ http
+ // 禁用 CSRF (跨站请求伪造) 保护,因为 JWT 是无状态的,不需要 CSRF 保护
+ .csrf(AbstractHttpConfigurer::disable)
+ // 配置 Session 管理策略为无状态,不使用 Session
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ // 配置请求的授权规则
+ .authorizeHttpRequests(auth -> auth
+ // 允许对 /auth/** 路径下的所有请求进行匿名访问 (例如登录、注册接口)
+ .requestMatchers("/auth/**").permitAll()
+ // 其他所有请求都需要进行身份认证
+ .anyRequest().authenticated()
+ )
+ // 禁用默认的表单登录功能,因为我们使用 JWT 进行认证
+ .formLogin(AbstractHttpConfigurer::disable)
+ // 禁用默认的 HTTP Basic 认证
+ .httpBasic(AbstractHttpConfigurer::disable)
+ // 将自定义的 JwtAuthenticationFilter 添加到 UsernamePasswordAuthenticationFilter 之前
+ // 确保在进行基于用户名密码的认证之前,先进行 JWT 认证
+ .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+
+ // 构建并返回配置好的 SecurityFilterChain
+ return http.build();
+ }
+
+ // 注意:JwtAuthenticationFilter 需要被 Spring 扫描到并作为 Bean 管理
+ // 确保 JwtAuthenticationFilter 类上有 @Component 或其他 Spring Bean 相关的注解
+}
+
+--- End of: ./config/SecurityConfig.java ---
+
+--- Content of: ./controller/AuthController.java ---
+package com.example.demo.controller;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager; // 导入 AuthenticationManager
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; // 导入 UsernamePasswordAuthenticationToken
+import org.springframework.security.core.Authentication; // 导入 Authentication
+import org.springframework.security.core.context.SecurityContextHolder; // 导入 SecurityContextHolder
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.example.demo.dto.LoginRequestDTO;
+import com.example.demo.dto.LoginResponseDTO;
+import com.example.demo.entity.User;
+import com.example.demo.exception.AuthException;
+import com.example.demo.security.JwtTokenUtil;
+import com.example.demo.service.UserService;
+
+import jakarta.validation.Valid;
+
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+ private final UserService userService;
+ private final PasswordEncoder passwordEncoder; // 虽然在AuthController中不再直接用于比对,但如果UserServiceImpl需要,这里保留注入
+ private final JwtTokenUtil jwtTokenUtil;
+ private final AuthenticationManager authenticationManager; // 注入 AuthenticationManager
+
+ public AuthController(UserService userService,
+ PasswordEncoder passwordEncoder,
+ JwtTokenUtil jwtTokenUtil,
+ AuthenticationManager authenticationManager) { // 注入 AuthenticationManager
+ this.userService = userService;
+ this.passwordEncoder = passwordEncoder;
+ this.jwtTokenUtil = jwtTokenUtil;
+ this.authenticationManager = authenticationManager; // 初始化 AuthenticationManager
+ }
+
+ @PostMapping("/login")
+ public ResponseEntity<?> login(
+ @Valid @RequestBody LoginRequestDTO loginRequest,
+ BindingResult bindingResult) {
+
+ if (bindingResult.hasErrors()) {
+ String errMsg = bindingResult.getFieldErrors().stream()
+ .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
+ .reduce((a, b) -> a + "; " + b)
+ .orElse("Invalid parameters");
+ return ResponseEntity.badRequest().body(Map.of("error", errMsg));
+ }
+
+ try {
+ // 使用 AuthenticationManager 进行认证
+ // 这会触发 UserServiceImpl 中的 loadUserByUsername 方法的调用,并比较密码
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(
+ loginRequest.getUsername(),
+ loginRequest.getPassword()
+ )
+ );
+ // 将认证信息设置到 Spring Security 的上下文中
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ // 获取认证后的用户详情(假设 User 实体实现了 UserDetails 接口)
+ User userDetails = (User) authentication.getPrincipal();
+
+ // 调用 UserService 方法生成登录响应(包括 JWT Token)
+ LoginResponseDTO response = userService.generateLoginResponse(userDetails);
+
+ return ResponseEntity.ok(response);
+
+ } catch (Exception e) {
+ // 认证失败(用户名不存在或密码错误),抛出自定义认证异常
+ // GlobalExceptionHandler 会捕获 AuthException 并返回 401
+ throw new AuthException("用户名或密码错误", e);
+ }
+
+ // 移除原有的手动查找用户和密码比对逻辑
+ /*
+ User user = userService.lambdaQuery()
+ .eq(User::getUsername, loginRequest.getUsername())
+ .one();
+
+ if (user == null || !passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
+ throw new AuthException("用户名或密码错误");
+ }
+
+ String token = jwtTokenUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
+
+ LoginResponseDTO response = new LoginResponseDTO();
+ response.setToken(token);
+ response.setExpiresAt(Instant.now().plusMillis(jwtTokenUtil.getExpiration()));
+ response.setUserId(user.getId());
+ response.setUsername(user.getUsername());
+ response.setRoles(List.of(user.getRole()));
+
+ return ResponseEntity.ok(response);
+ */
+ }
+}
+--- End of: ./controller/AuthController.java ---
+
+--- Content of: ./DemoApplication.java ---
+package com.example.demo;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+@SpringBootApplication
+@MapperScan("com.example.demo.mapper")
+public class DemoApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DemoApplication.class, args);
+ }
+
+}
+
+--- End of: ./DemoApplication.java ---
+
+--- Content of: ./dto/LoginRequestDTO.java ---
+package com.example.demo.dto;
+
+import java.io.Serializable;
+
+import jakarta.validation.constraints.NotBlank;
+
+
+public class LoginRequestDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @NotBlank(message = "用户名不能为空")
+ private String username;
+
+ @NotBlank(message = "密码不能为空")
+ private String password;
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+}
+--- End of: ./dto/LoginRequestDTO.java ---
+
+--- Content of: ./dto/LoginResponseDTO.java ---
+package com.example.demo.dto;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.List;
+
+public class LoginResponseDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 登录成功后返回的 JWT 或会话标识 */
+ private String token;
+
+ /** Token 过期时间戳 */
+ private Instant expiresAt;
+
+ /** 用户 ID,可选 */
+ private Long userId;
+
+ /** 用户名,可选 */
+ private String username;
+
+ /** 用户角色列表,可选 */
+ private List<String> roles;
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public List<String> getRoles() {
+ return roles;
+ }
+
+ public void setRoles(List<String> roles) {
+ this.roles = roles;
+ }
+}
+
+--- End of: ./dto/LoginResponseDTO.java ---
+
+--- Content of: ./entity/User.java ---
+package com.example.demo.entity;
+
+import java.time.LocalDateTime; // Keep import if Serializable is used elsewhere, otherwise remove
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List; // 用于返回空的权限集合,如果用户没有角色/权限
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority; // 用于构建权限列表
+import org.springframework.security.core.userdetails.UserDetails;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+
+@TableName("user")
+// 实现UserDetails接口 (UserDetails extends Serializable, so Serializable is redundant here)
+public class User implements UserDetails {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String username;
+
+ private String password;
+
+ private String email;
+
+ private Integer status; // 假设status字段表示用户状态,例如 0:禁用, 1:启用
+
+ private Integer score;
+
+ private String role; // 假设role字段存储用户的角色,例如 "ADMIN", "USER"
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+
+ // --- Getters and Setters ---
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ @Override // 实现UserDetails接口的getUsername方法
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ @Override // 实现UserDetails接口的getPassword方法
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public Integer getStatus() {
+ return status;
+ }
+
+ public void setStatus(Integer status) {
+ this.status = status;
+ }
+
+ public Integer getScore() {
+ return score;
+ }
+
+ public void setScore(Integer score) {
+ this.score = score;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+
+ public LocalDateTime getCreateTime() {
+ return createTime;
+ }
+
+ public void setCreateTime(LocalDateTime createTime) {
+ this.createTime = createTime;
+ }
+
+ public LocalDateTime getUpdateTime() {
+ return updateTime;
+ }
+
+ public void setUpdateTime(LocalDateTime updateTime) {
+ this.updateTime = updateTime;
+ }
+
+ // --- 实现 UserDetails 接口的方法 ---
+
+ @Override
+ public Collection<? extends GrantedAuthority> getAuthorities() {
+ // 根据用户的角色构建权限列表
+ List<GrantedAuthority> authorities = new ArrayList<>();
+ if (this.role != null && !this.role.isEmpty()) {
+ // Spring Security 推荐角色以 "ROLE_" 开头
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + this.role));
+ }
+ // 如果用户没有角色,返回一个空的集合
+ return authorities;
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ // 根据您的业务逻辑判断账户是否过期
+ // 例如,如果您的User实体有过期时间字段,可以在这里检查
+ // 这里简单返回true,表示账户永不过期
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ // 根据您的业务逻辑判断账户是否被锁定
+ // 例如,如果您的User实体有锁定状态字段,可以在这里检查
+ // 这里简单返回true,表示账户永不锁定
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ // 根据您的业务逻辑判断凭证(密码)是否过期
+ // 例如,如果您的User实体有密码最后修改时间字段,可以在这里检查
+ // 这里简单返回true,表示凭证永不过期
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ // 根据您的业务逻辑判断账户是否启用
+ // 假设status字段为1表示启用
+ return this.status != null && this.status == 1;
+ }
+}
+
+--- End of: ./entity/User.java ---
+
+--- Content of: ./exception/AuthException.java ---
+package com.example.demo.exception;
+
+/**
+ * 自定义认证/授权异常
+ */
+public class AuthException extends RuntimeException {
+ public AuthException(String message) {
+ super(message);
+ }
+
+ public AuthException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
+
+--- End of: ./exception/AuthException.java ---
+
+--- Content of: ./exception/GlobalExceptionHandler.java ---
+package com.example.demo.exception;
+
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ /** 处理自定义认证异常 */
+ @ExceptionHandler(AuthException.class)
+ public ResponseEntity<Map<String, String>> handleAuthException(AuthException ex) {
+ Map<String, String> body = Map.of("error", ex.getMessage());
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);
+ }
+
+ /** 处理请求参数校验失败 */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
+ Map<String, String> errors = new HashMap<>();
+ for (FieldError fe : ex.getBindingResult().getFieldErrors()) {
+ errors.put(fe.getField(), fe.getDefaultMessage());
+ }
+ return ResponseEntity.badRequest().body(errors);
+ }
+
+ /** 处理其它未捕获的异常 */
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity<Map<String, String>> handleAllExceptions(Exception ex) {
+ Map<String, String> body = Map.of("error", "Internal server error");
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
+ }
+}
+
+--- End of: ./exception/GlobalExceptionHandler.java ---
+
+--- Content of: ./mapper/UserMapper.java ---
+package com.example.demo.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.example.demo.entity.User;
+
+/**
+ * UserMapper 接口
+ *
+ * 继承 MyBatis-Plus 提供的 BaseMapper,实现基本的 CRUD 操作
+ */
+@Mapper
+public interface UserMapper extends BaseMapper<User> {
+
+ User findById(Long id);
+
+ User findByUsername(String username);
+
+ // List<User> selectByStatus(@Param("status") Integer status);
+}
+
+
+--- End of: ./mapper/UserMapper.java ---
+
+--- Content of: ./security/JwtAuthenticationFilter.java ---
+package com.example.demo.security;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import com.example.demo.config.JWTProperties;
+import com.example.demo.exception.AuthException;
+
+import io.jsonwebtoken.Claims;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenUtil tokenUtil;
+ private final JWTProperties props;
+
+ public JwtAuthenticationFilter(JwtTokenUtil tokenUtil, JWTProperties props) {
+ this.tokenUtil = tokenUtil;
+ this.props = props;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain chain)
+ throws ServletException, IOException {
+
+ String headerValue = request.getHeader(props.getHeader());
+ if (!StringUtils.hasText(headerValue) || !headerValue.startsWith(props.getTokenPrefix())) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ String token = headerValue.substring(props.getTokenPrefix().length());
+ if (!tokenUtil.validateToken(token)) {
+ throw new AuthException("Invalid or expired JWT token");
+ }
+
+ Claims claims = tokenUtil.parseClaims(token);
+ String username =tokenUtil.getUsername(token);
+ Long userId = tokenUtil.getUserId(token);
+ String role = tokenUtil.getRole(token);
+
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(
+ userId,
+ null,
+ List.of(new SimpleGrantedAuthority(role))
+ );
+ SecurityContextHolder.getContext().setAuthentication(auth);
+
+ chain.doFilter(request, response);
+ }
+}
+
+--- End of: ./security/JwtAuthenticationFilter.java ---
+
+--- Content of: ./security/JwtTokenUtil.java ---
+package com.example.demo.security;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import com.example.demo.config.JWTProperties;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+@Component
+public class JwtTokenUtil {
+
+ private final JWTProperties props;
+ private final byte[] secretBytes;
+
+ public JwtTokenUtil(JWTProperties props) {
+ this.props = props;
+ this.secretBytes = props.getSecret().getBytes(StandardCharsets.UTF_8);
+ }
+
+ /** 生成 JWT */
+ public String generateToken(Long userId, String username, String role) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + props.getExpirationMs());
+
+ return Jwts.builder()
+ .setSubject(username)
+ .setIssuer(props.getIssuer())
+ .setAudience(props.getAudience())
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .addClaims(Map.of("userId", userId, "role", role))
+ .signWith(Keys.hmacShaKeyFor(secretBytes), SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ /** 验证并解析 Token,抛出异常则验证失败 */
+ public Claims parseClaims(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(secretBytes)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ /** 校验 Token 是否有效 */
+ public boolean validateToken(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (JwtException e) {
+ return false;
+ }
+ }
+
+ /** 从 Token 获取用户名(Subject) */
+ public String getUsername(String token) {
+ return parseClaims(token).getSubject();
+ }
+
+ /** 从 Token 获取用户 ID */
+ public Long getUserId(String token) {
+ Object id = parseClaims(token).get("userId");
+ return id == null ? null : Long.valueOf(id.toString());
+ }
+
+ /** 从 Token 获取角色 */
+ public String getRole(String token) {
+ return parseClaims(token).get("role", String.class);
+ }
+
+ /**
+ * 获取 JWT 的过期时间(毫秒)
+ * 修复:返回 JWTProperties 中配置的过期时间
+ */
+ public long getExpiration() {
+ return props.getExpirationMs();
+ }
+}
+
+--- End of: ./security/JwtTokenUtil.java ---
+
+--- Content of: ./service/UserService.java ---
+package com.example.demo.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.example.demo.dto.LoginResponseDTO;
+import com.example.demo.entity.User; // 导入 LoginResponseDTO
+
+/**
+ * User 业务层接口
+ * 继承了 MyBatis-Plus 的 IService,并定义了自定义的用户业务方法。
+ */
+public interface UserService extends IService<User> {
+ // 自定义方法:根据ID查找用户
+ User findById(Long id);
+
+ // 自定义方法:根据用户名查找用户
+ User findByUsername(String username);
+
+ // 自定义方法:创建新用户
+ User createUser(User user);
+
+ // 新增方法:生成用户登录响应(包括 JWT Token)
+ // 这个方法在 AuthController 中被调用,因此必须在接口中声明
+ LoginResponseDTO generateLoginResponse(User userDetails);
+
+ // 如果需要其他自定义方法,可以在这里再添加
+}
+
+--- End of: ./service/UserService.java ---
+
+--- Content of: ./service/impl/UserServiceImpl.java ---
+package com.example.demo.service.impl;
+
+// 导入UserService接口
+import java.time.Instant;
+import java.util.List;
+
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.example.demo.dto.LoginResponseDTO;
+import com.example.demo.entity.User;
+import com.example.demo.mapper.UserMapper;
+import com.example.demo.security.JwtTokenUtil;
+import com.example.demo.service.UserService;
+
+
+/**
+ * 用户业务逻辑服务实现类
+ * 继承了MyBatis-Plus的ServiceImpl,并实现了UserService接口和Spring Security的UserDetailsService接口。
+ * 利用ServiceImpl提供的通用CRUD方法,并实现自定义的用户业务操作和加载用户详情。
+ * 用户登录认证逻辑已移至 Controller 层,以解决循环依赖问题。
+ */
+@Service
+// 继承ServiceImpl,指定Mapper和实体类型。
+// 这样就自动拥有了IService中大部分通用方法的实现。
+public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
+
+ // 当继承ServiceImpl后,ServiceImpl内部已经注入了BaseMapper,
+ // 通常不需要在这里再次声明UserMapper,除非您需要调用UserMapper中自定义的非BaseMapper方法。
+ // private final UserMapper userMapper; // 如果继承ServiceImpl,这行通常可以移除
+
+ // 注入密码编码器,用于密码加密和校验
+ private final PasswordEncoder passwordEncoder;
+ // 移除 AuthenticationManager 注入,认证逻辑已移至 Controller
+ // private final AuthenticationManager authenticationManager;
+ // 注入JWT工具类,用于生成和解析Token
+ private final JwtTokenUtil jwtTokenUtil;
+
+
+ /**
+ * 构造函数注入非BaseMapper的依赖
+ * ServiceImpl会自动注入BaseMapper
+ * @param passwordEncoder 密码编码器
+ * // 移除 authenticationManager 参数
+ * @param jwtTokenUtil JWT工具类
+ */
+ // @Autowired // 当只有一个构造函数时,@Autowired 可选
+ public UserServiceImpl(PasswordEncoder passwordEncoder,
+ // AuthenticationManager authenticationManager, // 移除此参数
+ JwtTokenUtil jwtTokenUtil) {
+ // 当继承ServiceImpl时,不需要在构造函数中注入并初始化BaseMapper
+ // this.userMapper = userMapper; // 移除这行
+
+ this.passwordEncoder = passwordEncoder;
+ // this.authenticationManager = authenticationManager; // 移除此初始化
+ this.jwtTokenUtil = jwtTokenUtil;
+ }
+
+ /**
+ * 根据用户ID查找用户
+ * 实现UserService接口中的findById方法。
+ * 可以直接使用ServiceImpl提供的getById方法。
+ * @param id 用户ID
+ * @return 找到的用户实体,如果不存在则返回null
+ */
+ @Override
+ public User findById(Long id) {
+ // 直接使用ServiceImpl提供的通用getById方法
+ return this.getById(id);
+ // 如果UserMapper有自定义的findById方法且需要使用,可以通过getBaseMapper()调用:
+ // return this.getBaseMapper().findById(id);
+ }
+
+ /**
+ * 根据用户名查找用户
+ * 实现UserService接口中的findByUsername方法。
+ * 可以使用ServiceImpl提供的getOne方法结合Wrapper进行条件查询。
+ * @param username 用户名
+ * @return 找到的用户实体,如果不存在则返回null
+ */
+ @Override
+ public User findByUsername(String username) {
+ // 使用ServiceImpl提供的通用getOne方法结合QueryWrapper进行条件查询
+ return this.getOne(new QueryWrapper<User>().eq("username", username));
+ // 如果UserMapper有自定义的findByUsername方法且需要使用,可以通过getBaseMapper()调用:
+ // return this.getBaseMapper().findByUsername(username);
+ }
+
+ /**
+ * 创建新用户
+ * 实现UserService接口中的createUser方法。
+ * 可以使用ServiceImpl提供的save方法。
+ * @param user 待创建的用户实体
+ * @return 创建成功的用户实体
+ */
+ @Override
+ public User createUser(User user) {
+ // 在保存前对用户密码进行加密
+ user.setPassword(passwordEncoder.encode(user.getPassword()));
+
+ // 使用ServiceImpl提供的通用save方法将用户插入数据库
+ this.save(user);
+ return user;
+ }
+
+ /**
+ * 实现Spring Security的UserDetailsService接口方法
+ * 根据用户名加载用户详情。
+ * 这个方法会被Spring Security的认证流程调用。
+ * @param username 用户名
+ * @return 实现UserDetails接口的用户详情对象
+ * @throws UsernameNotFoundException 如果用户不存在
+ */
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ // 调用findByUsername方法从数据库查找用户
+ User user = findByUsername(username);
+ if (user == null) {
+ // 如果用户不存在,抛出UsernameNotFoundException
+ throw new UsernameNotFoundException("User not found with username: " + username);
+ }
+
+ // 返回Spring Security需要的UserDetails对象。
+ // 假设您的User实体类实现了UserDetails接口,并且其getAuthorities()方法正确返回了权限列表。
+ return user;
+ }
+
+ /**
+ * 用户登录认证成功后,生成JWT Token和构建响应DTO。
+ * 这个方法实现了UserService接口中声明的 generateLoginResponse 方法。
+ * @param userDetails 认证成功的用户详情(User实体)
+ * @return 包含JWT Token和相关信息的登录响应DTO
+ */
+ @Override // 添加 @Override 注解,明确表示实现接口方法
+ public LoginResponseDTO generateLoginResponse(User userDetails) {
+ // 使用JwtTokenUtil生成JWT Token
+ String token = jwtTokenUtil.generateToken(userDetails.getId(), userDetails.getUsername(), userDetails.getRole());
+
+ // 构建登录响应DTO
+ LoginResponseDTO response = new LoginResponseDTO();
+ response.setToken(token);
+ // 调用 JwtTokenUtil 的 getExpiration() 方法获取过期时间毫秒数
+ response.setExpiresAt(Instant.now().plusMillis(jwtTokenUtil.getExpiration()));
+ response.setUserId(userDetails.getId());
+ response.setUsername(userDetails.getUsername());
+ response.setRoles(List.of(userDetails.getRole())); // 假设角色是单个字符串,如果多个角色需要调整
+
+ return response;
+ }
+
+
+ // 当继承ServiceImpl后,IService中的通用方法(如 save, updateById, list, page 等)
+ // 已经由ServiceImpl提供默认实现,无需在此重复编写。
+ // 如果您需要对IService中的某个通用方法进行定制(例如在保存前进行额外处理),
+ // 可以在这里@Override该方法并添加您的逻辑,然后调用super.方法名(...)来执行ServiceImpl的默认逻辑。
+ /*
+ @Override
+ public boolean saveBatch(Collection<User> entityList, int batchSize) {
+ // 在调用ServiceImpl的批量保存前/后添加自定义逻辑
+ // ...
+ return super.saveBatch(entityList, batchSize); // 调用ServiceImpl的默认实现
+ }
+
+ // ... 其他IService中的方法实现
+ */
+}
+
+--- End of: ./service/impl/UserServiceImpl.java ---
+
+--- End File Content ---
diff --git a/backend/demo/src/main/java/com/example/demo/security/JwtAuthenticationFilter.java b/backend/demo/src/main/java/com/example/demo/security/JwtAuthenticationFilter.java
index 108e966..520908c 100644
--- a/backend/demo/src/main/java/com/example/demo/security/JwtAuthenticationFilter.java
+++ b/backend/demo/src/main/java/com/example/demo/security/JwtAuthenticationFilter.java
@@ -47,7 +47,7 @@
}
Claims claims = tokenUtil.parseClaims(token);
- String username = claims.getSubject();
+ String username =tokenUtil.getUsername(token);
Long userId = tokenUtil.getUserId(token);
String role = tokenUtil.getRole(token);
diff --git a/backend/demo/src/main/java/com/example/demo/security/JwtTokenUtil.java b/backend/demo/src/main/java/com/example/demo/security/JwtTokenUtil.java
index 3f44d8a..7a9be5a 100644
--- a/backend/demo/src/main/java/com/example/demo/security/JwtTokenUtil.java
+++ b/backend/demo/src/main/java/com/example/demo/security/JwtTokenUtil.java
@@ -75,7 +75,11 @@
return parseClaims(token).get("role", String.class);
}
+ /**
+ * 获取 JWT 的过期时间(毫秒)
+ * 修复:返回 JWTProperties 中配置的过期时间
+ */
public long getExpiration() {
- throw new UnsupportedOperationException("Not supported yet.");
+ return props.getExpirationMs();
}
}
diff --git a/backend/demo/src/main/java/com/example/demo/service/TorrentService.java b/backend/demo/src/main/java/com/example/demo/service/TorrentService.java
new file mode 100644
index 0000000..b863798
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/service/TorrentService.java
@@ -0,0 +1,26 @@
+// src/main/java/com/example/demo/service/TorrentService.java
+package com.example.demo.service;
+
+import java.io.IOException;
+
+import org.springframework.web.multipart.MultipartFile;
+
+import com.example.demo.dto.TorrentInfoDTO;
+import com.turn.ttorrent.bcodec.InvalidBEncodingException; // 导入 InvalidBEncodingException
+
+/**
+ * 处理 .torrent 文件上传及相关业务逻辑的服务接口。
+ */
+public interface TorrentService {
+
+ /**
+ * 处理上传的 .torrent 文件,解析其内容,保存到数据库,并生成相关链接。
+ *
+ * @param file 上传的 MultipartFile 对象
+ * @return 包含解析信息、磁力链接和下载 URL 的 TorrentInfoDTO
+ * @throws IOException 如果文件读取或保存失败
+ * @throws InvalidBEncodingException 如果 .torrent 文件内容不符合 B 编码规范
+ * @throws IllegalArgumentException 如果文件为空或解析后的元数据不完整
+ */
+ TorrentInfoDTO handleUpload(MultipartFile file) throws IOException, InvalidBEncodingException, IllegalArgumentException;
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/service/UserService.java b/backend/demo/src/main/java/com/example/demo/service/UserService.java
index 60e8aef..e4c5d7b 100644
--- a/backend/demo/src/main/java/com/example/demo/service/UserService.java
+++ b/backend/demo/src/main/java/com/example/demo/service/UserService.java
@@ -1,13 +1,26 @@
package com.example.demo.service;
-
import com.baomidou.mybatisplus.extension.service.IService;
-import com.example.demo.entity.User;
+import com.example.demo.dto.LoginResponseDTO;
+import com.example.demo.entity.User; // 导入 LoginResponseDTO
/**
* User 业务层接口
+ * 继承了 MyBatis-Plus 的 IService,并定义了自定义的用户业务方法。
*/
public interface UserService extends IService<User> {
- // 如果需要自定义方法,可以在这里再添加,例如:
- // User findByUsername(String username);
+ // 自定义方法:根据ID查找用户
+ User findById(Long id);
+
+ // 自定义方法:根据用户名查找用户
+ User findByUsername(String username);
+
+ // 自定义方法:创建新用户
+ User createUser(User user);
+
+ // 新增方法:生成用户登录响应(包括 JWT Token)
+ // 这个方法在 AuthController 中被调用,因此必须在接口中声明
+ LoginResponseDTO generateLoginResponse(User userDetails);
+
+ // 如果需要其他自定义方法,可以在这里再添加
}
diff --git a/backend/demo/src/main/java/com/example/demo/service/impl/TorrentServiceImpl.java b/backend/demo/src/main/java/com/example/demo/service/impl/TorrentServiceImpl.java
new file mode 100644
index 0000000..eb00160
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/service/impl/TorrentServiceImpl.java
@@ -0,0 +1,126 @@
+// src/main/java/com/example/demo/service/impl/TorrentServiceImpl.java
+package com.example.demo.service.impl;
+
+import java.io.IOException;
+import java.nio.file.Files; // 导入 DTO
+import java.nio.file.Path; // 导入实体类
+import java.nio.file.Paths; // 导入 Mapper
+
+import org.springframework.beans.factory.annotation.Value; // 导入服务接口
+import org.springframework.stereotype.Service; // 导入工具类
+import org.springframework.transaction.annotation.Transactional; // 导入 ttorrent 的异常
+import org.springframework.web.multipart.MultipartFile; // 导入 @Value 用于读取配置
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.example.demo.dto.TorrentInfoDTO; // 导入事务注解
+import com.example.demo.entity.TorrentInfo;
+import com.example.demo.mapper.TorrentInfoMapper;
+import com.example.demo.service.TorrentService; // 用于文件操作
+import com.example.demo.util.TorrentUtils; // 用于文件路径
+import com.turn.ttorrent.bcodec.InvalidBEncodingException; // 用于文件路径
+
+/**
+ * TorrentService 接口的实现类,负责处理 .torrent 文件的上传、解析、持久化及链接生成。
+ */
+@Service // 标记这是一个 Spring Service 组件
+public class TorrentServiceImpl implements TorrentService {
+
+ private final TorrentInfoMapper torrentInfoMapper;
+
+ @Value("${file.upload-dir}") // 从 application.properties 中注入文件上传目录
+ private String uploadDir;
+
+ // 通过构造函数注入 Mapper
+ public TorrentServiceImpl(TorrentInfoMapper torrentInfoMapper) {
+ this.torrentInfoMapper = torrentInfoMapper;
+ }
+
+ @Override
+ @Transactional // 确保文件保存和数据库操作的原子性
+ public TorrentInfoDTO handleUpload(MultipartFile file) throws IOException, InvalidBEncodingException, IllegalArgumentException {
+ if (file.isEmpty()) {
+ throw new IllegalArgumentException("上传文件不能为空。");
+ }
+
+ // 1. 调用 TorrentUtils 解析 .torrent 文件
+ TorrentUtils.TorrentParsedInfo parsedInfo;
+ try {
+ parsedInfo = TorrentUtils.parseTorrentFile(file);
+ } catch (InvalidBEncodingException e) {
+ throw new IllegalArgumentException("无法解析 .torrent 文件,文件格式不正确: " + e.getMessage(), e);
+ } catch (IOException e) {
+ throw new IOException("读取上传文件失败: " + e.getMessage(), e);
+ }
+
+ String infoHash = parsedInfo.getInfoHash();
+ String fileName = parsedInfo.getFileName();
+ long fileSize = parsedInfo.getFileSize();
+
+ // 2. 检查 infoHash 是否已存在,避免重复上传相同的洪流
+ QueryWrapper<TorrentInfo> queryWrapper = new QueryWrapper<>();
+ queryWrapper.eq("info_hash", infoHash); // 根据 info_hash 查询
+ TorrentInfo existingTorrent = torrentInfoMapper.selectOne(queryWrapper);
+
+ if (existingTorrent != null) {
+ System.out.println("Torrent with infoHash " + infoHash + " already exists. Returning existing info.");
+ // 如果已存在,直接返回现有信息对应的 DTO
+ return new TorrentInfoDTO(
+ existingTorrent.getFileName(),
+ existingTorrent.getFileSize(),
+ existingTorrent.getInfoHash(),
+ existingTorrent.getMagnetUri(),
+ existingTorrent.getDownloadUrl(),
+ "该洪流已存在,返回现有信息。"
+ );
+ }
+
+ // 3. 保存 .torrent 文件到服务器文件系统
+ // 使用 infoHash 作为文件名,确保唯一性,并保留原始扩展名 .torrent
+ String storedFileName = infoHash + ".torrent";
+ Path uploadPath = Paths.get(uploadDir).toAbsolutePath().normalize();
+ Path filePath = uploadPath.resolve(storedFileName);
+
+ try {
+ Files.createDirectories(uploadPath); // 如果目录不存在则创建
+ Files.copy(file.getInputStream(), filePath); // 将文件流复制到目标路径
+ System.out.println("Torrent file saved to: " + filePath.toString());
+ } catch (IOException e) {
+ // 如果文件保存失败,需要回滚之前的数据库操作 (如果前面有的话)
+ // @Transactional 会自动处理
+ throw new IOException("无法保存 .torrent 文件到磁盘: " + e.getMessage(), e);
+ }
+
+ // 4. 生成 magnet 链接 & downloadUrl
+ String magnetUri = String.format("magnet:?xt=urn:btih:%s&dn=%s", infoHash, fileName);
+ // downloadUrl 指向后端实际的下载接口
+ String downloadUrl = String.format("/api/downloads/%s/%s", infoHash, fileName);
+
+ // 5. 组装实体 TorrentInfo
+ TorrentInfo torrentInfo = new TorrentInfo(
+ infoHash,
+ fileName,
+ fileSize,
+ magnetUri,
+ downloadUrl
+ );
+
+ // 6. 调用 Mapper.insert() 持久化一条记录到数据库
+ int insertedRows = torrentInfoMapper.insert(torrentInfo);
+ if (insertedRows != 1) {
+ throw new RuntimeException("保存洪流信息到数据库失败。");
+ }
+ System.out.println("Saved TorrentInfo to database: " + torrentInfo);
+
+ // 7. 构造 DTO 返回给前端
+ TorrentInfoDTO dto = new TorrentInfoDTO(
+ torrentInfo.getFileName(),
+ torrentInfo.getFileSize(),
+ torrentInfo.getInfoHash(),
+ torrentInfo.getMagnetUri(),
+ torrentInfo.getDownloadUrl(),
+ "Torrent uploaded and processed successfully!"
+ );
+
+ return dto;
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/java/com/example/demo/service/impl/UserServiceImpl.java b/backend/demo/src/main/java/com/example/demo/service/impl/UserServiceImpl.java
index 5bd1693..1fc434c 100644
--- a/backend/demo/src/main/java/com/example/demo/service/impl/UserServiceImpl.java
+++ b/backend/demo/src/main/java/com/example/demo/service/impl/UserServiceImpl.java
@@ -1,23 +1,171 @@
package com.example.demo.service.impl;
-// UserServiceImpl.java
+// 导入UserService接口
+import java.time.Instant;
+import java.util.List;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.example.demo.dto.LoginResponseDTO;
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
+import com.example.demo.security.JwtTokenUtil;
import com.example.demo.service.UserService;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+
/**
- * User 业务层默认实现
+ * 用户业务逻辑服务实现类
+ * 继承了MyBatis-Plus的ServiceImpl,并实现了UserService接口和Spring Security的UserDetailsService接口。
+ * 利用ServiceImpl提供的通用CRUD方法,并实现自定义的用户业务操作和加载用户详情。
+ * 用户登录认证逻辑已移至 Controller 层,以解决循环依赖问题。
*/
@Service
-@Transactional(rollbackFor = Exception.class)
-public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
- // 如果你在接口中声明了自定义方法,这里可以重写并实现:
- // @Override
- // public User findByUsername(String username) {
- // return lambdaQuery().eq(User::getUsername, username).one();
- // }
+// 继承ServiceImpl,指定Mapper和实体类型。
+// 这样就自动拥有了IService中大部分通用方法的实现。
+public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
+
+ // 当继承ServiceImpl后,ServiceImpl内部已经注入了BaseMapper,
+ // 通常不需要在这里再次声明UserMapper,除非您需要调用UserMapper中自定义的非BaseMapper方法。
+ // private final UserMapper userMapper; // 如果继承ServiceImpl,这行通常可以移除
+
+ // 注入密码编码器,用于密码加密和校验
+ private final PasswordEncoder passwordEncoder;
+ // 移除 AuthenticationManager 注入,认证逻辑已移至 Controller
+ // private final AuthenticationManager authenticationManager;
+ // 注入JWT工具类,用于生成和解析Token
+ private final JwtTokenUtil jwtTokenUtil;
+
+
+ /**
+ * 构造函数注入非BaseMapper的依赖
+ * ServiceImpl会自动注入BaseMapper
+ * @param passwordEncoder 密码编码器
+ * // 移除 authenticationManager 参数
+ * @param jwtTokenUtil JWT工具类
+ */
+ // @Autowired // 当只有一个构造函数时,@Autowired 可选
+ public UserServiceImpl(PasswordEncoder passwordEncoder,
+ // AuthenticationManager authenticationManager, // 移除此参数
+ JwtTokenUtil jwtTokenUtil) {
+ // 当继承ServiceImpl时,不需要在构造函数中注入并初始化BaseMapper
+ // this.userMapper = userMapper; // 移除这行
+
+ this.passwordEncoder = passwordEncoder;
+ // this.authenticationManager = authenticationManager; // 移除此初始化
+ this.jwtTokenUtil = jwtTokenUtil;
+ }
+
+ /**
+ * 根据用户ID查找用户
+ * 实现UserService接口中的findById方法。
+ * 可以直接使用ServiceImpl提供的getById方法。
+ * @param id 用户ID
+ * @return 找到的用户实体,如果不存在则返回null
+ */
+ @Override
+ public User findById(Long id) {
+ // 直接使用ServiceImpl提供的通用getById方法
+ return this.getById(id);
+ // 如果UserMapper有自定义的findById方法且需要使用,可以通过getBaseMapper()调用:
+ // return this.getBaseMapper().findById(id);
+ }
+
+ /**
+ * 根据用户名查找用户
+ * 实现UserService接口中的findByUsername方法。
+ * 可以使用ServiceImpl提供的getOne方法结合Wrapper进行条件查询。
+ * @param username 用户名
+ * @return 找到的用户实体,如果不存在则返回null
+ */
+ @Override
+ public User findByUsername(String username) {
+ // 使用ServiceImpl提供的通用getOne方法结合QueryWrapper进行条件查询
+ return this.getOne(new QueryWrapper<User>().eq("username", username));
+ // 如果UserMapper有自定义的findByUsername方法且需要使用,可以通过getBaseMapper()调用:
+ // return this.getBaseMapper().findByUsername(username);
+ }
+
+ /**
+ * 创建新用户
+ * 实现UserService接口中的createUser方法。
+ * 可以使用ServiceImpl提供的save方法。
+ * @param user 待创建的用户实体
+ * @return 创建成功的用户实体
+ */
+ @Override
+ public User createUser(User user) {
+ // 在保存前对用户密码进行加密
+ user.setPassword(passwordEncoder.encode(user.getPassword()));
+
+ // 使用ServiceImpl提供的通用save方法将用户插入数据库
+ this.save(user);
+ return user;
+ }
+
+ /**
+ * 实现Spring Security的UserDetailsService接口方法
+ * 根据用户名加载用户详情。
+ * 这个方法会被Spring Security的认证流程调用。
+ * @param username 用户名
+ * @return 实现UserDetails接口的用户详情对象
+ * @throws UsernameNotFoundException 如果用户不存在
+ */
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ // 调用findByUsername方法从数据库查找用户
+ User user = findByUsername(username);
+ if (user == null) {
+ // 如果用户不存在,抛出UsernameNotFoundException
+ throw new UsernameNotFoundException("User not found with username: " + username);
+ }
+
+ // 返回Spring Security需要的UserDetails对象。
+ // 假设您的User实体类实现了UserDetails接口,并且其getAuthorities()方法正确返回了权限列表。
+ return user;
+ }
+
+ /**
+ * 用户登录认证成功后,生成JWT Token和构建响应DTO。
+ * 这个方法实现了UserService接口中声明的 generateLoginResponse 方法。
+ * @param userDetails 认证成功的用户详情(User实体)
+ * @return 包含JWT Token和相关信息的登录响应DTO
+ */
+ @Override // 添加 @Override 注解,明确表示实现接口方法
+ public LoginResponseDTO generateLoginResponse(User userDetails) {
+ // 使用JwtTokenUtil生成JWT Token
+ String token = jwtTokenUtil.generateToken(userDetails.getId(), userDetails.getUsername(), userDetails.getRole());
+
+ // 构建登录响应DTO
+ LoginResponseDTO response = new LoginResponseDTO();
+ response.setToken(token);
+ // 调用 JwtTokenUtil 的 getExpiration() 方法获取过期时间毫秒数
+ response.setExpiresAt(Instant.now().plusMillis(jwtTokenUtil.getExpiration()));
+ response.setUserId(userDetails.getId());
+ response.setUsername(userDetails.getUsername());
+ response.setRoles(List.of(userDetails.getRole())); // 假设角色是单个字符串,如果多个角色需要调整
+
+ return response;
+ }
+
+
+ // 当继承ServiceImpl后,IService中的通用方法(如 save, updateById, list, page 等)
+ // 已经由ServiceImpl提供默认实现,无需在此重复编写。
+ // 如果您需要对IService中的某个通用方法进行定制(例如在保存前进行额外处理),
+ // 可以在这里@Override该方法并添加您的逻辑,然后调用super.方法名(...)来执行ServiceImpl的默认逻辑。
+ /*
+ @Override
+ public boolean saveBatch(Collection<User> entityList, int batchSize) {
+ // 在调用ServiceImpl的批量保存前/后添加自定义逻辑
+ // ...
+ return super.saveBatch(entityList, batchSize); // 调用ServiceImpl的默认实现
+ }
+
+ // ... 其他IService中的方法实现
+ */
}
diff --git a/backend/demo/src/main/java/com/example/demo/sh.py b/backend/demo/src/main/java/com/example/demo/sh.py
new file mode 100644
index 0000000..3be143b
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/sh.py
@@ -0,0 +1,76 @@
+import os
+
+def read_files_and_write_to_file(file_paths, output_file):
+ """
+ Reads the content of specified files and writes it to an output file.
+
+ Args:
+ file_paths (list): A list of strings, where each string is the path to a file.
+ output_file (str): The path to the file where the output will be written.
+ """
+ try:
+ # Open the output file in write mode ('w').
+ # Use 'utf-8' encoding for wide compatibility.
+ with open(output_file, 'w', encoding='utf-8') as outfile:
+ outfile.write("--- Start File Content ---\n")
+ print(f"Reading files and writing to {output_file}...") # Print to console for feedback
+
+ for file_path in file_paths:
+ if not os.path.exists(file_path):
+ error_msg = f"Error: File not found at path: {file_path}\n"
+ outfile.write(error_msg)
+ print(error_msg.strip()) # Also print error to console
+ continue
+ if not os.path.isfile(file_path):
+ error_msg = f"Error: Path is not a file: {file_path}\n"
+ outfile.write(error_msg)
+ print(error_msg.strip()) # Also print error to console
+ continue
+
+ try:
+ with open(file_path, 'r', encoding='utf-8') as infile:
+ content = infile.read()
+ outfile.write(f"\n--- Content of: {file_path} ---\n")
+ outfile.write(content)
+ outfile.write(f"\n--- End of: {file_path} ---\n")
+ print(f"Successfully read: {file_path}") # Print success to console
+ except Exception as e:
+ error_msg = f"Error reading file {file_path}: {e}\n"
+ outfile.write(error_msg)
+ print(error_msg.strip()) # Also print error to console
+
+ outfile.write("\n--- End File Content ---\n")
+ print("Finished writing file content.")
+
+ except Exception as e:
+ print(f"Error opening or writing to output file {output_file}: {e}")
+
+
+if __name__ == "__main__":
+ # 请在这里列出您想要读取的文件路径
+ # 确保路径是相对于您运行脚本的位置,或者使用绝对路径
+ your_file_paths = [
+ './config/JWTProperties.java',
+ './config/SecurityConfig.java',
+ './controller/AuthController.java',
+ './DemoApplication.java',
+ './dto/LoginRequestDTO.java',
+ './dto/LoginResponseDTO.java',
+ './entity/User.java',
+ './exception/AuthException.java',
+ './exception/GlobalExceptionHandler.java',
+ './mapper/UserMapper.java',
+ './security/JwtAuthenticationFilter.java',
+ './security/JwtTokenUtil.java',
+ './service/UserService.java',
+ './service/impl/UserServiceImpl.java', # 假设您已经创建了这个文件
+
+ ]
+
+ # 指定输出文件的名称
+ output_filename = "result.txt"
+
+ # 调用函数,将文件内容写入到 output_filename
+ read_files_and_write_to_file(your_file_paths, output_filename)
+
+
diff --git a/backend/demo/src/main/java/com/example/demo/util/TorrentUtils.java b/backend/demo/src/main/java/com/example/demo/util/TorrentUtils.java
new file mode 100644
index 0000000..465a167
--- /dev/null
+++ b/backend/demo/src/main/java/com/example/demo/util/TorrentUtils.java
@@ -0,0 +1,102 @@
+// src/main/java/com/example/demo/util/TorrentUtils.java
+package com.example.demo.util;
+
+import java.io.IOException; // 导入 TorrentMetadata 接口
+
+import org.springframework.web.multipart.MultipartFile; // 导入 TorrentParser 类
+
+import com.turn.ttorrent.bcodec.InvalidBEncodingException;
+import com.turn.ttorrent.common.TorrentMetadata;
+import com.turn.ttorrent.common.TorrentParser;
+
+public class TorrentUtils {
+
+ /**
+ * 解析 MultipartFile 中的 .torrent 文件,提取关键信息。
+ *
+ * 该方法利用 ttorrent-common 库中的 TorrentParser 来解析 .torrent 文件的字节内容,
+ * 并从解析得到的 TorrentMetadata 对象中提取 info hash、文件名和文件大小。
+ *
+ * @param file 上传的 MultipartFile 对象
+ * @return 包含解析信息的 TorrentParsedInfo 对象
+ * @throws IOException 如果文件读取失败
+ * @throws BDecodingException 如果 .torrent 文件内容不符合 B 编码规范
+ * @throws IllegalArgumentException 如果文件为空或解析后的元数据不完整
+ */
+ public static TorrentParsedInfo parseTorrentFile(MultipartFile file)
+ throws IOException, IllegalArgumentException {
+
+ if (file.isEmpty()) {
+ throw new IllegalArgumentException("Uploaded file is empty.");
+ }
+
+ byte[] torrentBytes = file.getBytes();
+
+ // 使用 ttorrent-common 库的 TorrentParser 来解析字节数组
+ TorrentParser parser = new TorrentParser();
+ TorrentMetadata metadata;
+ try {
+ metadata = parser.parse(torrentBytes);
+ } catch (com.turn.ttorrent.bcodec.InvalidBEncodingException e) {
+ // 将 InvalidBEncodingException 转换为 BDecodingException
+ throw new InvalidBEncodingException("Invalid B-encoding in .torrent file: " + e.getMessage());
+ } catch (RuntimeException e) {
+ // TorrentParser.parse 可能会包装 IOException 为 RuntimeException
+ if (e.getCause() instanceof IOException) {
+ throw (IOException) e.getCause();
+ }
+ throw e; // 重新抛出其他 RuntimeException
+ }
+
+ // 从 TorrentMetadata 对象中提取所需信息
+ String infoHash = metadata.getHexInfoHash(); // 获取十六进制表示的 info hash
+ String fileName = metadata.getDirectoryName(); // 获取洪流的名称(通常是目录名或单文件名)
+ long totalSize = 0;
+
+ // 计算总文件大小
+ // TorrentMetadata.getFiles() 返回 List<TorrentFile>
+ // TorrentFile 包含 size 字段
+ if (metadata.getFiles() != null && !metadata.getFiles().isEmpty()) {
+ for (com.turn.ttorrent.common.TorrentFile torrentFile : metadata.getFiles()) {
+ totalSize += torrentFile.size;
+ }
+ } else {
+ // 如果没有文件列表,可能是单文件模式,但 TorrentMetadata 接口没有直接提供总大小
+ // 在实际应用中,如果 TorrentMetadata 无法直接提供总大小,
+ // 并且 getFiles() 为空,可能需要回溯到原始的 BEValue 来获取 'length' 字段
+ // 但根据 TorrentMetadata 接口,getFiles() 应该能覆盖所有情况。
+ System.err.println("Warning: Could not determine total file size from TorrentMetadata.getFiles().");
+ }
+
+
+ // 构建并返回解析信息
+ return new TorrentParsedInfo(infoHash, fileName, totalSize);
+ }
+
+ /**
+ * 内部类,用于封装解析后的洪流信息。
+ */
+ public static class TorrentParsedInfo {
+ private final String infoHash;
+ private final String fileName;
+ private final long fileSize;
+
+ public TorrentParsedInfo(String infoHash, String fileName, long fileSize) {
+ this.infoHash = infoHash;
+ this.fileName = fileName;
+ this.fileSize = fileSize;
+ }
+
+ public String getInfoHash() {
+ return infoHash;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public long getFileSize() {
+ return fileSize;
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/demo/src/main/resources/META-INF/additional-spring-configuration-metadata.json
new file mode 100644
index 0000000..cf06b61
--- /dev/null
+++ b/backend/demo/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -0,0 +1,5 @@
+{"properties": [{
+ "name": "file.upload-dir",
+ "type": "java.lang.String",
+ "description": "Torrent dir for uploading'"
+}]}
\ No newline at end of file
diff --git a/backend/demo/src/main/resources/application.properties b/backend/demo/src/main/resources/application.properties
index 4472028..9bbb45b 100644
--- a/backend/demo/src/main/resources/application.properties
+++ b/backend/demo/src/main/resources/application.properties
@@ -1,5 +1,5 @@
# ========== 数据源 ==========
-spring.datasource.url=jdbc:mysql://mysql:3306/mydatabase?serverTimezone=Asia/Shanghai
+spring.datasource.url=jdbc:mysql://mysql:3306/mydatabase?serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=TRUE&useSSL=FALSE&allowPublicKeyRetrieval=TRUE
spring.datasource.username=myuser
spring.datasource.password=secret
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
@@ -10,3 +10,6 @@
# ========== 日志输出(可选) ==========
logging.level.org.springframework.security=DEBUG
+
+file.upload-dir=./torrents
+
diff --git a/backend/demo/src/test/java/com/example/demo/controller/AuthControllerMockTest.java b/backend/demo/src/test/java/com/example/demo/controller/AuthControllerMockTest.java
new file mode 100644
index 0000000..fd98df3
--- /dev/null
+++ b/backend/demo/src/test/java/com/example/demo/controller/AuthControllerMockTest.java
@@ -0,0 +1,229 @@
+package com.example.demo.controller; // 请根据您的项目结构调整包名
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks; // 注入 Mock 到被测试对象
+import org.mockito.Mock; // 标记为 Mock 对象
+import org.mockito.MockitoAnnotations; // 初始化 Mockito 注解
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException; // 模拟认证失败异常
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext; // 模拟 SecurityContext
+import org.springframework.security.core.context.SecurityContextHolder; // 模拟 SecurityContextHolder
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.validation.BindingResult; // 模拟 BindingResult
+import org.springframework.validation.FieldError; // 模拟 FieldError
+
+import com.example.demo.dto.LoginRequestDTO;
+import com.example.demo.dto.LoginResponseDTO;
+import com.example.demo.entity.User;
+import com.example.demo.exception.AuthException;
+import com.example.demo.security.JwtTokenUtil;
+import com.example.demo.service.UserService;
+
+/**
+ * AuthController 的纯 Mock 测试类
+ * 不依赖 Spring 上下文或真实数据库
+ */
+public class AuthControllerMockTest {
+
+ // 使用 @Mock 注解标记需要模拟的依赖对象
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private PasswordEncoder passwordEncoder; // 在 AuthController 中虽然注入了,但登录逻辑中未使用,这里依然模拟
+
+ @Mock
+ private JwtTokenUtil jwtTokenUtil;
+
+ @Mock
+ private AuthenticationManager authenticationManager;
+
+ @Mock
+ private BindingResult bindingResult; // 模拟请求体校验结果
+
+ @Mock
+ private SecurityContext securityContext; // 模拟 SecurityContext
+
+ // 使用 @InjectMocks 注解将上面 @Mock 标记的依赖注入到 AuthController 实例中
+ @InjectMocks
+ private AuthController authController;
+
+ /**
+ * 在每个测试方法执行前初始化 Mock 对象
+ */
+ @BeforeEach
+ void setUp() {
+ // 初始化所有 @Mock 注解标记的对象
+ MockitoAnnotations.openMocks(this);
+
+ // 模拟 SecurityContextHolder 的行为,使其返回我们模拟的 SecurityContext
+ // 这是为了让 SecurityContextHolder.getContext().setAuthentication() 调用时不会报错
+ when(securityContext.getAuthentication()).thenReturn(null); // 默认返回null
+ SecurityContextHolder.setContext(securityContext);
+ }
+
+ /**
+ * 测试用户登录成功场景
+ */
+ @Test
+ void testLoginSuccess() {
+ // --- 准备测试数据和模拟行为 ---
+
+ // 模拟 LoginRequestDTO
+ LoginRequestDTO loginRequest = new LoginRequestDTO();
+ loginRequest.setUsername("testuser");
+ loginRequest.setPassword("password123");
+
+ // 模拟 BindingResult 没有错误
+ when(bindingResult.hasErrors()).thenReturn(false);
+
+ // 模拟 AuthenticationManager 认证成功
+ // 创建一个模拟的 Authentication 对象
+ Authentication mockAuthentication = mock(Authentication.class);
+ // 模拟认证成功后,principal 返回 UserDetails (这里是 User 实体)
+ User mockUser = new User();
+ mockUser.setId(1L);
+ mockUser.setUsername("testuser");
+ mockUser.setRole("USER");
+ when(mockAuthentication.getPrincipal()).thenReturn(mockUser);
+
+ // 当调用 authenticationManager.authenticate() 时,返回模拟的认证对象
+ when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
+ .thenReturn(mockAuthentication);
+
+ // 模拟 UserService.generateLoginResponse 方法的返回值
+ LoginResponseDTO mockLoginResponse = new LoginResponseDTO();
+ mockLoginResponse.setToken("mocked_jwt_token");
+ mockLoginResponse.setExpiresAt(Instant.now().plusSeconds(3600));
+ mockLoginResponse.setUserId(mockUser.getId());
+ mockLoginResponse.setUsername(mockUser.getUsername());
+ mockLoginResponse.setRoles(Collections.singletonList(mockUser.getRole()));
+
+ when(userService.generateLoginResponse(mockUser)).thenReturn(mockLoginResponse);
+
+ // --- 执行被测试方法 ---
+ ResponseEntity<?> responseEntity = authController.login(loginRequest, bindingResult);
+
+ // --- 验证结果 ---
+
+ // 验证响应状态码是否为 200 OK
+ assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
+
+ // 验证响应体是否是预期的 LoginResponseDTO
+ assertNotNull(responseEntity.getBody());
+ assertTrue(responseEntity.getBody() instanceof LoginResponseDTO);
+
+ LoginResponseDTO responseBody = (LoginResponseDTO) responseEntity.getBody();
+ assertEquals("mocked_jwt_token", responseBody.getToken());
+ assertEquals(mockUser.getId(), responseBody.getUserId());
+ assertEquals(mockUser.getUsername(), responseBody.getUsername());
+ assertEquals(Collections.singletonList(mockUser.getRole()), responseBody.getRoles());
+ assertNotNull(responseBody.getExpiresAt()); // 验证过期时间不为null
+
+ // 验证 Mock 依赖的方法是否被正确调用
+ verify(bindingResult).hasErrors(); // 验证是否检查了 BindingResult
+ verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); // 验证是否调用了认证管理器
+ verify(userService).generateLoginResponse(mockUser); // 验证是否调用了生成响应的方法
+ verify(securityContext).setAuthentication(mockAuthentication); // 验证认证对象是否设置到了 SecurityContext
+ verifyNoMoreInteractions(userService, passwordEncoder, jwtTokenUtil, authenticationManager); // 验证没有调用其他未预期的 Mock 方法
+ }
+
+ /**
+ * 测试用户登录失败场景:认证失败 (例如用户名或密码错误)
+ */
+ @Test
+ void testLoginFailureAuthentication() {
+ // --- 准备测试数据和模拟行为 ---
+
+ // 模拟 LoginRequestDTO
+ LoginRequestDTO loginRequest = new LoginRequestDTO();
+ loginRequest.setUsername("wronguser");
+ loginRequest.setPassword("wrongpassword");
+
+ // 模拟 BindingResult 没有错误
+ when(bindingResult.hasErrors()).thenReturn(false);
+
+ // 模拟 AuthenticationManager 认证失败,抛出 BadCredentialsException (Spring Security 认证失败的常见异常)
+ when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
+ .thenThrow(new BadCredentialsException("Bad credentials")); // 模拟认证失败
+
+ // --- 执行被测试方法并验证异常 ---
+ // 由于 AuthController 会捕获认证异常并抛出 AuthException,我们断言抛出了 AuthException
+ AuthException thrown = assertThrows(AuthException.class, () -> {
+ authController.login(loginRequest, bindingResult);
+ });
+
+ // 验证抛出的 AuthException 包含预期的错误信息
+ assertEquals("用户名或密码错误", thrown.getMessage());
+
+ // --- 验证 Mock 依赖的方法是否被正确调用 ---
+ verify(bindingResult).hasErrors(); // 验证是否检查了 BindingResult
+ verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); // 验证是否调用了认证管理器
+ verifyNoInteractions(userService); // 验证在认证失败时,没有调用 userService 的方法
+ verifyNoMoreInteractions(userService, passwordEncoder, jwtTokenUtil, authenticationManager); // 验证没有调用其他未预期的 Mock 方法
+ verify(securityContext, never()).setAuthentication(any()); // 验证认证对象没有设置到 SecurityContext
+ }
+
+ /**
+ * 测试请求参数校验失败场景
+ */
+ @Test
+ void testLoginFailureValidation() {
+ // --- 准备测试数据和模拟行为 ---
+
+ // 模拟 LoginRequestDTO (内容不重要,因为我们将模拟校验失败)
+ LoginRequestDTO loginRequest = new LoginRequestDTO();
+ loginRequest.setUsername("");
+ loginRequest.setPassword("");
+
+ // 模拟 BindingResult 有错误
+ when(bindingResult.hasErrors()).thenReturn(true);
+
+ // 模拟 BindingResult 返回的错误信息
+ FieldError usernameError = new FieldError("loginRequestDTO", "username", "用户名不能为空");
+ FieldError passwordError = new FieldError("loginRequestDTO", "password", "密码不能为空");
+ when(bindingResult.getFieldErrors()).thenReturn(List.of(usernameError, passwordError));
+
+
+ // --- 执行被测试方法 ---
+ ResponseEntity<?> responseEntity = authController.login(loginRequest, bindingResult);
+
+ // --- 验证结果 ---
+
+ // 验证响应状态码是否为 400 Bad Request
+ assertEquals(HttpStatus.BAD_REQUEST, responseEntity.getStatusCode());
+
+ // 验证响应体是否包含预期的错误信息 Map
+ assertNotNull(responseEntity.getBody());
+ assertTrue(responseEntity.getBody() instanceof java.util.Map);
+
+ @SuppressWarnings("unchecked")
+ java.util.Map<String, String> errorBody = (java.util.Map<String, String>) responseEntity.getBody();
+ assertTrue(errorBody.containsKey("error"));
+ // 可以进一步验证错误信息的具体内容,但这里只验证格式
+ // assertEquals("username: 用户名不能为空; password: 密码不能为空", errorBody.get("error")); // 如果您的错误信息拼接逻辑是这样
+
+ // --- 验证 Mock 依赖的方法是否被正确调用 ---
+ verify(bindingResult).hasErrors(); // 验证是否检查了 BindingResult
+ verify(bindingResult).getFieldErrors(); // 验证是否获取了字段错误
+ verifyNoInteractions(authenticationManager, userService, jwtTokenUtil, passwordEncoder); // 验证在校验失败时,没有调用认证、服务或 JWT 工具方法
+ verify(securityContext, never()).setAuthentication(any()); // 验证认证对象没有设置到 SecurityContext
+ }
+
+ // 您可以根据需要添加更多测试场景,例如:
+ // - 测试用户状态禁用时的行为 (如果您的 UserDetails 实现中 isEnabled() 会检查状态)
+ // - 测试不同角色返回时的 LoginResponseDTO 内容
+}
diff --git a/backend/demo/src/test/java/com/example/demo/controller/TorrentControllerTest.java b/backend/demo/src/test/java/com/example/demo/controller/TorrentControllerTest.java
new file mode 100644
index 0000000..e0f2c8d
--- /dev/null
+++ b/backend/demo/src/test/java/com/example/demo/controller/TorrentControllerTest.java
@@ -0,0 +1,228 @@
+package com.example.demo.controller;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.example.demo.dto.TorrentInfoDTO;
+import com.example.demo.service.TorrentService;
+import com.turn.ttorrent.bcodec.InvalidBEncodingException;
+
+/**
+ * Unit tests for the {@link TorrentController} focusing on file upload.
+ * 使用 Mockito 和 MockMvc 对 TorrentController 的文件上传功能进行单元测试。
+ */
+@ExtendWith(MockitoExtension.class) // 使用 MockitoExtension 来初始化 mocks 和 injectMocks
+class TorrentControllerTest {
+
+ private MockMvc mockMvc; // MockMvc 用于模拟 HTTP 请求
+
+ @Mock // 创建 TorrentService 的 mock 对象
+ private TorrentService torrentService;
+
+ @InjectMocks // 创建 TorrentController 实例并注入 mock 的 TorrentService
+ private TorrentController torrentController;
+
+ @BeforeEach // 在每个测试方法执行前设置 MockMvc
+ void setUp() {
+ // 初始化 MockMvc,使用独立的设置,只测试 TorrentController
+ mockMvc = MockMvcBuilders.standaloneSetup(torrentController)
+ // 可以添加全局异常处理器等,如果控制器中定义了的话
+ // .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ // 在这里可以设置 @Value("${file.upload-dir}") 的值,如果需要的话
+ // ReflectionTestUtils.setField(torrentController, "uploadDir", "test-upload-dir");
+ }
+
+ /**
+ * Test successful upload of a .torrent file.
+ * 测试成功上传 .torrent 文件的情况。
+ */
+ @Test
+ void uploadTorrent_Success() throws Exception {
+ // 1. 准备 mock 数据和行为 (Prepare mock data and behavior)
+ // 创建一个模拟的 MultipartFile
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file", // 参数名,必须与 @RequestParam("file") 一致 (Parameter name, must match @RequestParam("file"))
+ "test.torrent", // 原始文件名 (Original filename)
+ MediaType.APPLICATION_OCTET_STREAM_VALUE, // 文件类型 (File type)
+ "d8:announce39:http://tracker.example.com/announce4:info...".getBytes() // 模拟的 .torrent 文件内容 (Simulated .torrent file content)
+ );
+
+ // 创建预期的 TorrentInfoDTO 返回结果
+ TorrentInfoDTO expectedDto = new TorrentInfoDTO();
+ expectedDto.setFileName("test.torrent");
+ expectedDto.setInfoHash("mockInfoHash");
+ expectedDto.setMagnetUri("magnet:?xt=urn:btih:mockInfoHash");
+ expectedDto.setDownloadUrl("/api/downloads/mockInfoHash/test.torrent");
+ expectedDto.setMessage("文件上传成功。"); // Success message
+
+ // 当 torrentService.handleUpload 被调用时,返回预期的 DTO
+ // When torrentService.handleUpload is called, return the expected DTO
+ when(torrentService.handleUpload(any(MultipartFile.class))).thenReturn(expectedDto);
+
+ // 2. 执行请求并验证结果 (Execute the request and verify the results)
+ mockMvc.perform(multipart("/api/torrents") // 发送 POST 请求到 /api/torrents (Send POST request to /api/torrents)
+ .file(mockFile) // 附加文件 (Attach the file)
+ .contentType(MediaType.MULTIPART_FORM_DATA)) // 设置内容类型 (Set content type)
+ .andExpect(status().isOk()) // 验证 HTTP 状态码为 200 OK (Verify HTTP status code is 200 OK)
+ .andExpect(jsonPath("$.fileName").value("test.torrent")) // 验证返回 JSON 中的 fileName 字段 (Verify fileName field in the response JSON)
+ .andExpect(jsonPath("$.infoHash").value("mockInfoHash")) // 验证 infoHash 字段 (Verify infoHash field)
+ .andExpect(jsonPath("$.message").value("文件上传成功。")); // 验证 message 字段 (Verify message field)
+ }
+
+ /**
+ * Test uploading an empty file.
+ * 测试上传空文件的情况。
+ */
+ @Test
+ void uploadTorrent_EmptyFile() throws Exception {
+ MockMultipartFile emptyFile = new MockMultipartFile(
+ "file",
+ "empty.torrent",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ new byte[0] // 空文件内容 (Empty file content)
+ );
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(emptyFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isBadRequest()) // 验证 HTTP 状态码为 400 Bad Request (Verify HTTP status code is 400 Bad Request)
+ .andExpect(jsonPath("$.message").value("上传文件不能为空。")); // 验证错误消息 (Verify error message)
+ }
+
+ /**
+ * Test uploading a file with an unsupported extension.
+ * 测试上传不支持的文件扩展名的情况。
+ */
+ @Test
+ void uploadTorrent_UnsupportedFileType() throws Exception {
+ MockMultipartFile wrongTypeFile = new MockMultipartFile(
+ "file",
+ "test.txt", // 非 .torrent 文件 (Not a .torrent file)
+ MediaType.TEXT_PLAIN_VALUE,
+ "This is not a torrent file.".getBytes()
+ );
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(wrongTypeFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isUnsupportedMediaType()) // 验证 HTTP 状态码为 415 Unsupported Media Type (Verify HTTP status code is 415 Unsupported Media Type)
+ .andExpect(jsonPath("$.message").value("只允许上传 .torrent 文件。")); // 验证错误消息 (Verify error message)
+ }
+
+ /**
+ * Test handling of InvalidBEncodingException from the service layer.
+ * 测试处理来自服务层的 InvalidBEncodingException。
+ */
+ @Test
+ void uploadTorrent_InvalidBEncodingException() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "invalid.torrent",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ "invalid bencoding data".getBytes()
+ );
+
+ // 配置 torrentService.handleUpload 在被调用时抛出 InvalidBEncodingException
+ // Configure torrentService.handleUpload to throw InvalidBEncodingException when called
+ when(torrentService.handleUpload(any(MultipartFile.class)))
+ .thenThrow(new InvalidBEncodingException("Mocked BEncoding error"));
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(mockFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isBadRequest()) // 验证 HTTP 状态码为 400 Bad Request (Verify HTTP status code is 400 Bad Request)
+ .andExpect(jsonPath("$.message").value("文件解析失败,请确保它是有效的 .torrent 文件: Mocked BEncoding error")); // 验证错误消息 (Verify error message)
+ }
+
+ /**
+ * Test handling of IOException from the service layer.
+ * 测试处理来自服务层的 IOException。
+ */
+ @Test
+ void uploadTorrent_IOException() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "ioerror.torrent",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ "d8:announce...".getBytes()
+ );
+
+ // 配置 torrentService.handleUpload 在被调用时抛出 IOException
+ // Configure torrentService.handleUpload to throw IOException when called
+ when(torrentService.handleUpload(any(MultipartFile.class)))
+ .thenThrow(new IOException("Mocked IO error"));
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(mockFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isInternalServerError()) // 验证 HTTP 状态码为 500 Internal Server Error (Verify HTTP status code is 500 Internal Server Error)
+ .andExpect(jsonPath("$.message").value("文件上传或保存失败: Mocked IO error")); // 验证错误消息 (Verify error message)
+ }
+
+ /**
+ * Test handling of a generic Exception from the service layer.
+ * 测试处理来自服务层的通用 Exception。
+ */
+ @Test
+ void uploadTorrent_GenericException() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "genericerror.torrent",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ "d8:announce...".getBytes()
+ );
+
+ // 配置 torrentService.handleUpload 在被调用时抛出 RuntimeException
+ // Configure torrentService.handleUpload to throw RuntimeException when called
+ when(torrentService.handleUpload(any(MultipartFile.class)))
+ .thenThrow(new RuntimeException("Mocked generic error"));
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(mockFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isInternalServerError()) // 验证 HTTP 状态码为 500 Internal Server Error (Verify HTTP status code is 500 Internal Server Error)
+ .andExpect(jsonPath("$.message").value("文件上传过程中发生未知错误: Mocked generic error")); // 验证错误消息 (Verify error message)
+ }
+
+ /**
+ * Test handling of IllegalArgumentException from the service layer.
+ * 测试处理来自服务层的 IllegalArgumentException。
+ */
+ @Test
+ void uploadTorrent_IllegalArgumentExceptionFromService() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "illegalarg.torrent",
+ MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ "d8:announce...".getBytes()
+ );
+
+ // 配置 torrentService.handleUpload 在被调用时抛出 IllegalArgumentException
+ // Configure torrentService.handleUpload to throw IllegalArgumentException when called
+ when(torrentService.handleUpload(any(MultipartFile.class)))
+ .thenThrow(new IllegalArgumentException("Mocked illegal argument from service"));
+
+ mockMvc.perform(multipart("/api/torrents")
+ .file(mockFile)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isBadRequest()) // 验证 HTTP 状态码为 400 Bad Request (Verify HTTP status code is 400 Bad Request)
+ .andExpect(jsonPath("$.message").value("处理文件时发生错误: Mocked illegal argument from service")); // 验证错误消息 (Verify error message)
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/test/java/com/example/demo/security/JwtTokenUtilTest.java b/backend/demo/src/test/java/com/example/demo/security/JwtTokenUtilTest.java
new file mode 100644
index 0000000..e32e591
--- /dev/null
+++ b/backend/demo/src/test/java/com/example/demo/security/JwtTokenUtilTest.java
@@ -0,0 +1,281 @@
+package com.example.demo.security; // 请根据您的项目结构调整包名
+
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test; // Import MockitoSettings
+import org.junit.jupiter.api.extension.ExtendWith; // Import Strictness
+import org.mockito.Mock;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import com.example.demo.config.JWTProperties;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SignatureException;
+
+/**
+ * 单元测试 JwtTokenUtil,手动实例化并注入 Mock 依赖
+ */
+@ExtendWith(MockitoExtension.class)
+// Add this annotation to make stubbings in this test class lenient
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class JwtTokenUtilTest {
+
+ @Mock
+ private JWTProperties jwtProperties;
+
+ private JwtTokenUtil jwtTokenUtil;
+
+ private byte[] testSecretBytes;
+ // Ensure secret key is long enough for HS256 (at least 32 bytes or 256 bits)
+ private final String testSecret = "ThisIsATestSecretKeyForMockingPurposesAndItNeedsToBeLongEnoughToMeetHS256Requirements";
+ private final long testExpirationMs = 3600000L; // 1 hour
+
+ /**
+ * Initialize Mock objects' behavior and test data before each test method.
+ * With @ExtendWith(MockitoExtension.class), mock objects are initialized
+ * before this method runs.
+ */
+ @BeforeEach
+ void setUp() {
+ // Stub JWTProperties to provide data before creating the instance
+ // Using lenient() or @MockitoSettings(strictness = Strictness.LENIENT)
+ // allows stubbings that are not used by every test method.
+ when(jwtProperties.getSecret()).thenReturn(testSecret);
+ when(jwtProperties.getExpirationMs()).thenReturn(testExpirationMs);
+ when(jwtProperties.getIssuer()).thenReturn("test-issuer");
+ when(jwtProperties.getAudience()).thenReturn("test-audience");
+ when(jwtProperties.getHeader()).thenReturn("Authorization");
+ when(jwtProperties.getTokenPrefix()).thenReturn("Bearer ");
+
+ // Create the instance to be tested, injecting the mocked properties
+ // This is done AFTER stubbing the necessary methods for the constructor.
+ jwtTokenUtil = new JwtTokenUtil(jwtProperties);
+
+ // Calculate testSecretBytes (used for manually parsing/building tokens in tests)
+ testSecretBytes = testSecret.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void testGenerateToken() {
+ Long userId = 123L;
+ String username = "testuser";
+ String role = "USER";
+
+ String token = jwtTokenUtil.generateToken(userId, username, role);
+
+ assertNotNull(token);
+ assertFalse(token.isEmpty());
+
+ // Verify the generated token can be correctly parsed and contains expected Claims
+ Claims claims = Jwts.parserBuilder()
+ .setSigningKey(testSecretBytes) // Use the derived secret bytes
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+
+ assertEquals(username, claims.getSubject());
+ assertEquals("test-issuer", claims.getIssuer());
+ assertEquals("test-audience", claims.getAudience());
+ assertNotNull(claims.getIssuedAt());
+ assertNotNull(claims.getExpiration());
+ assertTrue(claims.getExpiration().getTime() > claims.getIssuedAt().getTime());
+ assertEquals(userId.intValue(), claims.get("userId", Integer.class));
+ assertEquals(role, claims.get("role", String.class));
+ }
+
+ @Test
+ void testParseClaimsValidToken() {
+ Long userId = 456L;
+ String username = "anotheruser";
+ String role = "ADMIN";
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String validToken = Jwts.builder()
+ .setSubject(username)
+ .setIssuer("test-issuer")
+ .setAudience("test-audience")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .addClaims(Map.of("userId", userId, "role", role))
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ Claims claims = jwtTokenUtil.parseClaims(validToken);
+
+ assertNotNull(claims);
+ assertEquals(username, claims.getSubject());
+ assertEquals("test-issuer", claims.getIssuer());
+ assertEquals("test-audience", claims.getAudience());
+ assertEquals(userId.intValue(), claims.get("userId", Integer.class));
+ assertEquals(role, claims.get("role", String.class));
+ }
+
+ @Test
+ void testParseClaimsInvalidSignature() {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String invalidToken = Jwts.builder()
+ .setSubject("someuser")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ // Use a different secret key for signing
+ .signWith(Keys.hmacShaKeyFor("AnotherSecretKeyAnotherSecretKey".getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertThrows(SignatureException.class, () -> jwtTokenUtil.parseClaims(invalidToken));
+ }
+
+ @Test
+ void testParseClaimsExpiredToken() {
+ // Create an expired token
+ Date past = new Date(System.currentTimeMillis() - testExpirationMs - 1000); // Expiration time is in the past
+ Date expiry = new Date(past.getTime() + 1000); // Expires shortly after being issued
+ String expiredToken = Jwts.builder()
+ .setSubject("expireduser")
+ .setIssuedAt(past)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertThrows(ExpiredJwtException.class, () -> jwtTokenUtil.parseClaims(expiredToken));
+ }
+
+ @Test
+ void testValidateTokenValid() {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String validToken = Jwts.builder()
+ .setSubject("validuser")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .addClaims(Map.of("userId", 789L, "role", "GUEST"))
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertTrue(jwtTokenUtil.validateToken(validToken));
+ }
+
+ @Test
+ void testValidateTokenInvalidSignature() {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String invalidToken = Jwts.builder()
+ .setSubject("invaliduser")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor("AnotherSecretKeyAnotherSecretKey".getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertFalse(jwtTokenUtil.validateToken(invalidToken));
+ }
+
+ @Test
+ void testValidateTokenExpired() {
+ Date past = new Date(System.currentTimeMillis() - testExpirationMs - 1000);
+ Date expiry = new Date(past.getTime() + 1000);
+ String expiredToken = Jwts.builder()
+ .setSubject("expireduser")
+ .setIssuedAt(past)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertFalse(jwtTokenUtil.validateToken(expiredToken));
+ }
+
+ @Test
+ void testGetUsername() {
+ String username = "extractuser";
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String token = Jwts.builder()
+ .setSubject(username)
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertEquals(username, jwtTokenUtil.getUsername(token));
+ }
+
+ @Test
+ void testGetUserId() {
+ Long userId = 999L;
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String token = Jwts.builder()
+ .setSubject("someuser")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .addClaims(Map.of("userId", userId))
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertEquals(userId, jwtTokenUtil.getUserId(token));
+ }
+
+ @Test
+ void testGetUserIdWhenClaimMissing() {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String token = Jwts.builder()
+ .setSubject("userwithoutid")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertNull(jwtTokenUtil.getUserId(token));
+ }
+
+ @Test
+ void testGetRole() {
+ String role = "MANAGER";
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String token = Jwts.builder()
+ .setSubject("someuser")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .addClaims(Map.of("role", role))
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertEquals(role, jwtTokenUtil.getRole(token));
+ }
+
+ @Test
+ void testGetRoleWhenClaimMissing() {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + testExpirationMs);
+ String token = Jwts.builder()
+ .setSubject("userwithoutrole")
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(Keys.hmacShaKeyFor(testSecretBytes), SignatureAlgorithm.HS256)
+ .compact();
+
+ assertNull(jwtTokenUtil.getRole(token));
+ }
+
+ @Test
+ void testGetExpiration() {
+ assertEquals(testExpirationMs, jwtTokenUtil.getExpiration());
+ }
+}
diff --git a/backend/demo/src/test/java/com/example/demo/service/impl/TorrentServiceImplTest.java b/backend/demo/src/test/java/com/example/demo/service/impl/TorrentServiceImplTest.java
new file mode 100644
index 0000000..69eb5ba
--- /dev/null
+++ b/backend/demo/src/test/java/com/example/demo/service/impl/TorrentServiceImplTest.java
@@ -0,0 +1,462 @@
+// src/test/java/com/example/demo/service/impl/TorrentServiceImplTest.java
+package com.example.demo.service.impl;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.example.demo.dto.TorrentInfoDTO;
+import com.example.demo.entity.TorrentInfo;
+import com.example.demo.mapper.TorrentInfoMapper;
+import com.example.demo.util.TorrentUtils;
+import com.example.demo.util.TorrentUtils.TorrentParsedInfo;
+import com.turn.ttorrent.bcodec.InvalidBEncodingException;
+
+/**
+ * Test class for TorrentServiceImpl.
+ * Uses JUnit 5 and Mockito for unit testing.
+ */
+@ExtendWith(MockitoExtension.class) // Enables Mockito annotations for JUnit 5
+class TorrentServiceImplTest {
+
+ @Mock // Creates a mock instance of TorrentInfoMapper
+ private TorrentInfoMapper torrentInfoMapper;
+
+ // @InjectMocks injects the mocks into the TorrentServiceImpl instance
+ // It will try to inject 'torrentInfoMapper' into the constructor of TorrentServiceImpl
+ @InjectMocks
+ private TorrentServiceImpl torrentService;
+
+ // This field will be injected into the service using ReflectionTestUtils
+ private String uploadDir = "test_temp_upload_dir";
+
+ /**
+ * Setup method executed before each test.
+ * It injects the 'uploadDir' value and ensures the test directory is clean.
+ *
+ * @throws IOException if directory operations fail
+ */
+ @BeforeEach
+ void setUp() throws IOException {
+ // Inject the 'uploadDir' field into the torrentService instance.
+ // This simulates Spring's @Value injection for testing purposes.
+ ReflectionTestUtils.setField(torrentService, "uploadDir", uploadDir);
+
+ // Define the path for the temporary upload directory used in tests.
+ Path testUploadPath = Paths.get(uploadDir);
+
+ // Clean up the directory before each test to ensure a consistent state.
+ if (Files.exists(testUploadPath)) {
+ // Use try-with-resources for the stream to ensure it's closed
+ try (Stream<Path> walk = Files.walk(testUploadPath)) {
+ walk.sorted(Comparator.reverseOrder()) // Process deeper paths first
+ .forEach(path -> {
+ try {
+ Files.delete(path); // Delete file or empty directory
+ } catch (IOException e) {
+ System.err.println("Failed to delete path " + path + ": " + e.getMessage());
+ // In a real scenario, you might want to re-throw or handle more robustly
+ // if cleanup failure is critical for subsequent tests.
+ }
+ });
+ } // Stream is closed here
+ }
+ // Always create the directory for the current test.
+ Files.createDirectories(testUploadPath);
+ }
+
+ /**
+ * Test case for successfully handling a new torrent file upload.
+ * Verifies parsing, file saving, database insertion, and DTO creation.
+ *
+ * @throws Exception if any unexpected error occurs during the test
+ */
+ @Test
+ @DisplayName("Should successfully handle a new torrent file upload")
+ void handleUpload_NewTorrent_Success() throws Exception {
+ // 1. Prepare a mock MultipartFile for input
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file", // parameter name
+ "example.torrent", // original filename
+ "application/x-bittorrent", // content type
+ "dummy torrent content".getBytes() // file content as bytes
+ );
+
+ // 2. Prepare mock data for TorrentUtils.TorrentParsedInfo result
+ String testInfoHash = "AABBCCDDEEFF11223344556677889900AABBCCDD";
+ String testFileName = "MyAwesomeMovie.mp4";
+ long testFileSize = 1024000L; // 1 MB
+
+ TorrentParsedInfo mockParsedInfo = new TorrentParsedInfo(
+ testInfoHash,
+ testFileName,
+ testFileSize
+ );
+
+ // Mock static method call for TorrentUtils.parseTorrentFile
+ // Mockito.mockStatic is used to mock static methods. It needs to be in a try-with-resources block.
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class)) {
+ // Define the behavior of the static method when called with any MultipartFile.
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenReturn(mockParsedInfo);
+
+ // 3. Mock TorrentInfoMapper behavior
+ // Simulate that no existing torrent is found in the database for the given infoHash.
+ when(torrentInfoMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ // Simulate successful insertion into the database (insert method returns 1 for success).
+ when(torrentInfoMapper.insert(any(TorrentInfo.class))).thenReturn(1);
+
+ // 4. Call the service method under test
+ TorrentInfoDTO result = torrentService.handleUpload(mockFile);
+
+ // 5. Assertions: Verify the returned DTO and side effects
+
+ // Ensure the result DTO is not null.
+ assertNotNull(result, "Returned DTO should not be null");
+ // Verify that the DTO contains the correct parsed information.
+ assertEquals(testFileName, result.getFileName(), "File name in DTO should match parsed info");
+ assertEquals(testFileSize, result.getFileSize(), "File size in DTO should match parsed info");
+ assertEquals(testInfoHash, result.getInfoHash(), "Info hash in DTO should match parsed info");
+ // Verify that magnet URI and download URL are correctly formatted and contain the info hash.
+ assertTrue(result.getMagnetUri().contains(testInfoHash), "Magnet URI should contain info hash");
+ assertTrue(result.getDownloadUrl().contains(testInfoHash), "Download URL should contain info hash");
+ // Verify the success message.
+ assertEquals("Torrent uploaded and processed successfully!", result.getMessage(), "Success message should be present");
+
+ // Verify interactions with mocks
+ // Verify that selectOne was called exactly once with any QueryWrapper.
+ verify(torrentInfoMapper, times(1)).selectOne(any(QueryWrapper.class));
+ // Verify that insert was called exactly once with any TorrentInfo object.
+ verify(torrentInfoMapper, times(1)).insert(any(TorrentInfo.class));
+ // Verify that TorrentUtils.parseTorrentFile was called exactly once.
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+
+ // Verify file was saved to the correct location on disk
+ Path expectedFilePath = Paths.get(uploadDir, testInfoHash + ".torrent");
+ assertTrue(Files.exists(expectedFilePath), "Torrent file should be saved to disk");
+ // Optionally, verify the content of the saved file if needed
+ // assertEquals("dummy torrent content", new String(Files.readAllBytes(expectedFilePath)));
+ }
+ }
+
+ /**
+ * Test case for handling a torrent file that already exists in the database.
+ * Verifies that existing information is returned and no new file is saved or DB entry created.
+ *
+ * @throws Exception if any unexpected error occurs during the test
+ */
+ @Test
+ @DisplayName("Should return existing info if torrent already exists")
+ void handleUpload_ExistingTorrent_ReturnsExistingInfo() throws Exception {
+ // 1. Prepare a mock MultipartFile for input (content doesn't matter much here)
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "existing.torrent",
+ "application/x-bittorrent",
+ "some content".getBytes()
+ );
+
+ // 2. Prepare mock data for TorrentUtils.TorrentParsedInfo result
+ String existingInfoHash = "EXISTINGHASH11223344556677889900EXISTINGHASH";
+ String existingFileName = "ExistingMovie.mkv";
+ long existingFileSize = 5000000L; // 5 MB
+
+ TorrentParsedInfo mockParsedInfo = new TorrentParsedInfo(
+ existingInfoHash,
+ existingFileName,
+ existingFileSize
+ );
+
+ // 3. Prepare an existing TorrentInfo entity that would be returned by the mapper
+ TorrentInfo existingTorrentInDb = new TorrentInfo(
+ existingInfoHash,
+ existingFileName,
+ existingFileSize,
+ "magnet:?xt=urn:btih:" + existingInfoHash + "&dn=" + existingFileName,
+ "/api/downloads/" + existingInfoHash + "/" + existingFileName
+ );
+ existingTorrentInDb.setId(1L); // Simulate an ID from the database
+ existingTorrentInDb.setUploadTime(LocalDateTime.now().minusDays(1)); // Simulate an existing upload time
+
+ // Mock static method call for TorrentUtils.parseTorrentFile
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class)) {
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenReturn(mockParsedInfo);
+
+ // 4. Mock TorrentInfoMapper behavior: Simulate that the torrent DOES exist
+ when(torrentInfoMapper.selectOne(any(QueryWrapper.class))).thenReturn(existingTorrentInDb);
+
+ // 5. Call the service method
+ TorrentInfoDTO result = torrentService.handleUpload(mockFile);
+
+ // 6. Assertions
+ assertNotNull(result, "Returned DTO should not be null");
+ // Verify that the DTO contains the information from the existing DB entry.
+ assertEquals(existingFileName, result.getFileName(), "File name should match existing DB entry");
+ assertEquals(existingFileSize, result.getFileSize(), "File size should match existing DB entry");
+ assertEquals(existingInfoHash, result.getInfoHash(), "Info hash should match existing DB entry");
+ assertEquals(existingTorrentInDb.getMagnetUri(), result.getMagnetUri(), "Magnet URI should match existing DB entry");
+ assertEquals(existingTorrentInDb.getDownloadUrl(), result.getDownloadUrl(), "Download URL should match existing DB entry");
+ // Verify the specific message for existing torrents.
+ assertEquals("该洪流已存在,返回现有信息。", result.getMessage(), "Message should indicate existing torrent");
+
+ // Verify interactions with mocks
+ // selectOne should be called once to check for existence.
+ verify(torrentInfoMapper, times(1)).selectOne(any(QueryWrapper.class));
+ // insert should NOT be called because the torrent already exists.
+ verify(torrentInfoMapper, never()).insert(any(TorrentInfo.class));
+ // TorrentUtils.parseTorrentFile should still be called once to get the info hash.
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+
+ // Verify that no new file was saved to disk
+ Path expectedFilePath = Paths.get(uploadDir, existingInfoHash + ".torrent");
+ assertFalse(Files.exists(expectedFilePath), "Torrent file should NOT be saved to disk if already exists");
+ }
+ }
+
+ /**
+ * Test case for uploading an empty MultipartFile.
+ * Expects an IllegalArgumentException.
+ */
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for empty file upload")
+ void handleUpload_EmptyFile_ThrowsIllegalArgumentException() {
+ // Prepare an empty mock MultipartFile
+ MockMultipartFile emptyFile = new MockMultipartFile(
+ "file",
+ "empty.torrent",
+ "application/x-bittorrent",
+ new byte[0] // Empty content
+ );
+
+ // Assert that calling handleUpload with an empty file throws IllegalArgumentException.
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
+ torrentService.handleUpload(emptyFile);
+ }, "Should throw IllegalArgumentException for empty file");
+
+ // Verify the error message.
+ assertEquals("上传文件不能为空。", thrown.getMessage(), "Error message should indicate empty file");
+
+ // Verify no interactions with mocks (as the check happens early)
+ verifyNoInteractions(torrentInfoMapper);
+ // We don't need to mock TorrentUtils here as the check happens before its call.
+ }
+
+ /**
+ * Test case for when TorrentUtils.parseTorrentFile throws InvalidBEncodingException.
+ * Expects an IllegalArgumentException with a specific message.
+ *
+ * @throws IOException if an unexpected IO error occurs
+ */
+ @Test
+ @DisplayName("Should throw IllegalArgumentException when TorrentUtils throws InvalidBEncodingException")
+ void handleUpload_InvalidBEncoding_ThrowsIllegalArgumentException() throws IOException {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "corrupt.torrent",
+ "application/x-bittorrent",
+ "corrupt content".getBytes()
+ );
+
+ // Mock static method call for TorrentUtils.parseTorrentFile to throw InvalidBEncodingException
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class)) {
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenThrow(new InvalidBEncodingException("Malformed B-encoded data"));
+
+ // Assert that calling handleUpload throws IllegalArgumentException.
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
+ torrentService.handleUpload(mockFile);
+ }, "Should throw IllegalArgumentException for invalid B-encoding");
+
+ // Verify the error message.
+ assertTrue(thrown.getMessage().contains("无法解析 .torrent 文件,文件格式不正确"), "Error message should indicate invalid format");
+ assertTrue(thrown.getCause() instanceof InvalidBEncodingException, "Cause should be InvalidBEncodingException");
+
+ // Verify interactions
+ verifyNoInteractions(torrentInfoMapper); // No DB interaction if parsing fails
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+ }
+ }
+
+ /**
+ * Test case for when TorrentUtils.parseTorrentFile throws IOException during file reading.
+ * Expects an IOException.
+ */
+ @Test
+ @DisplayName("Should throw IOException when TorrentUtils throws IOException during parsing")
+ void handleUpload_IOExceptionDuringParsing_ThrowsIOException() {
+ // Prepare a basic mock file. Only isEmpty() is relevant here before parsing.
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "error.torrent",
+ "application/x-bittorrent",
+ "dummy content".getBytes() // Content doesn't matter as parsing is mocked to fail immediately
+ );
+
+ // Mock static method call for TorrentUtils.parseTorrentFile to throw IOException
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class)) {
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenThrow(new IOException("Simulated parsing IO error"));
+
+ // Assert that calling handleUpload throws IOException.
+ IOException thrown = assertThrows(IOException.class, () -> {
+ torrentService.handleUpload(mockFile);
+ }, "Should throw IOException for parsing IO error");
+
+ // Verify the error message.
+ assertTrue(thrown.getMessage().contains("读取上传文件失败"), "Error message should indicate file read failure");
+ assertTrue(thrown.getCause() instanceof IOException, "Cause should be IOException");
+
+ // Verify interactions
+ verifyNoInteractions(torrentInfoMapper); // No DB interaction if parsing fails
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+ }
+ }
+
+ /**
+ * Test case for when file saving to disk fails (IOException).
+ * Verifies that an IOException is thrown and database operations are not committed.
+ *
+ * @throws Exception if any unexpected error occurs during the test
+ */
+ @Test
+ @DisplayName("Should throw IOException when file saving to disk fails")
+ void handleUpload_FileSaveFails_ThrowsIOException() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "save_fail.torrent",
+ "application/x-bittorrent",
+ "content".getBytes()
+ );
+
+ String testInfoHash = "FAILHASH11223344556677889900FAILHASH";
+ String testFileName = "FailSave.txt";
+ long testFileSize = 500L;
+
+ TorrentParsedInfo mockParsedInfo = new TorrentParsedInfo(
+ testInfoHash,
+ testFileName,
+ testFileSize
+ );
+
+ // Use MockedStatic for Files.copy to simulate IO failure
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class);
+ MockedStatic<Files> mockedFiles = Mockito.mockStatic(Files.class, CALLS_REAL_METHODS)) { // CALLS_REAL_METHODS allows other Files methods to work normally
+
+ // Mock TorrentUtils.parseTorrentFile
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenReturn(mockParsedInfo);
+
+ // Mock torrentInfoMapper.selectOne
+ when(torrentInfoMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
+ // Mock Files.copy to throw IOException when called with any InputStream and Path
+ // Note: mockFile.getInputStream() returns a ByteArrayInputStream
+ mockedFiles.when(() -> Files.copy(any(ByteArrayInputStream.class), any(Path.class)))
+ .thenThrow(new IOException("Simulated file copy error"));
+
+ // Now, call the service method
+ IOException thrown = assertThrows(IOException.class, () -> {
+ torrentService.handleUpload(mockFile);
+ }, "Should throw IOException when file saving fails");
+
+ assertTrue(thrown.getMessage().contains("无法保存 .torrent 文件到磁盘"), "Error message should indicate file save failure");
+
+ // Verify interactions
+ verify(torrentInfoMapper, times(1)).selectOne(any(QueryWrapper.class));
+ // insert should NOT be called if file saving fails (due to @Transactional rollback)
+ verify(torrentInfoMapper, never()).insert(any(TorrentInfo.class));
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+ mockedFiles.verify(() -> Files.copy(any(ByteArrayInputStream.class), any(Path.class)), times(1));
+
+ // No need to check Files.exists as we mocked the copy operation.
+ }
+ }
+
+ /**
+ * Test case for when the database insert operation fails (e.g., returns 0 rows affected).
+ * Expects a RuntimeException.
+ *
+ * @throws Exception if any unexpected error occurs during the test
+ */
+ @Test
+ @DisplayName("Should throw RuntimeException when database insert fails")
+ void handleUpload_DatabaseInsertFails_ThrowsRuntimeException() throws Exception {
+ MockMultipartFile mockFile = new MockMultipartFile(
+ "file",
+ "db_fail.torrent",
+ "application/x-bittorrent",
+ "db content".getBytes()
+ );
+
+ String testInfoHash = "DBFAILHASH11223344556677889900DBFAIL";
+ String testFileName = "DBFail.txt";
+ long testFileSize = 700L;
+
+ TorrentParsedInfo mockParsedInfo = new TorrentParsedInfo(
+ testInfoHash,
+ testFileName,
+ testFileSize
+ );
+
+ try (MockedStatic<TorrentUtils> mockedTorrentUtils = Mockito.mockStatic(TorrentUtils.class)) {
+ mockedTorrentUtils.when(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)))
+ .thenReturn(mockParsedInfo);
+
+ when(torrentInfoMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ // Simulate database insert failure (insert method returns 0 rows affected).
+ when(torrentInfoMapper.insert(any(TorrentInfo.class))).thenReturn(0);
+
+ // Assert that calling handleUpload throws RuntimeException.
+ RuntimeException thrown = assertThrows(RuntimeException.class, () -> {
+ torrentService.handleUpload(mockFile);
+ }, "Should throw RuntimeException when DB insert fails");
+
+ // Verify the error message.
+ assertEquals("保存洪流信息到数据库失败。", thrown.getMessage(), "Error message should indicate DB insert failure");
+
+ // Verify interactions
+ verify(torrentInfoMapper, times(1)).selectOne(any(QueryWrapper.class));
+ verify(torrentInfoMapper, times(1)).insert(any(TorrentInfo.class));
+ mockedTorrentUtils.verify(() -> TorrentUtils.parseTorrentFile(any(MultipartFile.class)), times(1));
+
+ // Verify that the file was saved (as the DB failure happens after file saving)
+ Path expectedFilePath = Paths.get(uploadDir, testInfoHash + ".torrent");
+ assertTrue(Files.exists(expectedFilePath), "Torrent file should be saved even if DB insert fails (before rollback)");
+ // In a real scenario with @Transactional, the file save might also be rolled back
+ // if the file system operation was part of a transactional resource, but typically it's not.
+ // Here, we verify the file exists as the exception occurs AFTER file saving.
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/demo/src/test/java/com/example/demo/util/TorrentByteReader.java b/backend/demo/src/test/java/com/example/demo/util/TorrentByteReader.java
new file mode 100644
index 0000000..c6bacbb
--- /dev/null
+++ b/backend/demo/src/test/java/com/example/demo/util/TorrentByteReader.java
@@ -0,0 +1,72 @@
+// src/main/java/com/example/demo/util/TorrentByteReader.java
+package com.example.demo.util;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import com.turn.ttorrent.common.TorrentMetadata;
+import com.turn.ttorrent.common.TorrentParser;
+
+public class TorrentByteReader {
+
+ public static void main(String[] args) {
+ // TODO: 请将此处替换为您真实的单个 .torrent 文件路径
+ String torrentFilePathString = "C:\\Users\\Acer\\Desktop\\复杂度的渐近表示.pdf.torrent";
+ // 示例:String torrentFilePathString = "/home/user/torrents/my_single_file.torrent";
+
+ Path torrentFilePath = Paths.get(torrentFilePathString);
+
+ try {
+ // 读取 .torrent 文件的所有字节
+ byte[] torrentBytes = Files.readAllBytes(torrentFilePath);
+
+ // 使用 ttorrent 库解析 .torrent 文件,获取 infoHash、文件名和文件大小
+ TorrentParser parser = new TorrentParser();
+ TorrentMetadata metadata = parser.parseFromFile(new File(torrentFilePathString));
+
+ String infoHash = metadata.getHexInfoHash();
+ String fileName = metadata.getDirectoryName(); // 对于单文件,通常是文件名
+ long fileSize = metadata.getFiles().stream().mapToLong(f -> f.size).sum();
+
+ System.out.println("=====================================================================");
+ System.out.println("请将以下数据复制并粘贴到您的测试文件中的相应 TODO 位置:");
+ System.out.println("=====================================================================");
+
+ System.out.println("\n// REAL_SINGLE_FILE_TORRENT_BYTES:");
+ System.out.print("public static final byte[] REAL_SINGLE_FILE_TORRENT_BYTES = {");
+ for (int i = 0; i < torrentBytes.length; i++) {
+ System.out.printf("(byte)0x%02X", torrentBytes[i]);
+ if (i < torrentBytes.length - 1) {
+ System.out.print(", ");
+ if ((i + 1) % 16 == 0) { // 每 16 个字节换行,提高可读性
+ System.out.print("\n ");
+ }
+ }
+ }
+ System.out.println("};");
+
+ System.out.println("\n// REAL_SINGLE_FILE_INFO_HASH:");
+ System.out.printf("public static final String REAL_SINGLE_FILE_INFO_HASH = \"%s\";%n", infoHash);
+
+ System.out.println("\n// REAL_SINGLE_FILE_NAME:");
+ System.out.printf("public static final String REAL_SINGLE_FILE_NAME = \"%s\";%n", fileName);
+
+ System.out.println("\n// REAL_SINGLE_FILE_SIZE:");
+ System.out.printf("public static final long REAL_SINGLE_FILE_SIZE = %dL;%n", fileSize);
+
+ System.out.println("\n=====================================================================");
+ System.out.println("请确保您填充的数据与您实际的 .torrent 文件内容完全匹配。");
+ System.out.println("=====================================================================");
+
+ } catch (IOException e) {
+ System.err.println("读取文件失败,请检查文件路径和权限: " + e.getMessage());
+ e.printStackTrace();
+ } catch (Exception e) { // 捕获 ttorrent 解析可能抛出的异常
+ System.err.println("解析 .torrent 文件失败: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file