实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
- 用户注册、登录、JWT 鉴权、用户信息接口
|
||||
- 个人网盘上传、下载、删除、目录管理、分页列表
|
||||
- CQU 课表与成绩聚合接口
|
||||
- 快传会话与浏览器间 P2P 信令接口
|
||||
- Swagger 文档、统一异常、日志输出
|
||||
|
||||
## 环境要求
|
||||
@@ -32,7 +32,6 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
`dev` 环境特点:
|
||||
|
||||
- 数据库使用 H2 文件库
|
||||
- CQU 接口返回 mock 数据
|
||||
- 方便和 `vue/` 前端直接联调
|
||||
|
||||
JWT 启动要求:
|
||||
@@ -54,39 +53,20 @@ JWT 启动要求:
|
||||
|
||||
## 旧库升级
|
||||
|
||||
如果服务器数据库是按旧版脚本初始化的,需要先补齐下面这些字段,否则登录后的首页接口可能在查询用户、课表或成绩时直接报 500。
|
||||
如果服务器数据库是按旧版脚本初始化的,旧教务相关字段和表可以保留但不会再被当前代码使用。新环境请直接使用最新初始化脚本,不再创建教务缓存表。
|
||||
|
||||
MySQL:
|
||||
|
||||
```sql
|
||||
ALTER TABLE portal_user
|
||||
ADD COLUMN last_school_student_id VARCHAR(64) NULL,
|
||||
ADD COLUMN last_school_semester VARCHAR(64) NULL;
|
||||
|
||||
ALTER TABLE portal_course
|
||||
ADD COLUMN semester VARCHAR(64) NULL,
|
||||
ADD COLUMN student_id VARCHAR(64) NULL;
|
||||
|
||||
ALTER TABLE portal_grade
|
||||
ADD COLUMN student_id VARCHAR(64) NULL;
|
||||
|
||||
CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id);
|
||||
CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
|
||||
DROP TABLE IF EXISTS portal_course;
|
||||
DROP TABLE IF EXISTS portal_grade;
|
||||
```
|
||||
|
||||
openGauss:
|
||||
|
||||
```sql
|
||||
ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_student_id VARCHAR(64);
|
||||
ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_semester VARCHAR(64);
|
||||
|
||||
ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS semester VARCHAR(64);
|
||||
ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS student_id VARCHAR(64);
|
||||
|
||||
ALTER TABLE portal_grade ADD COLUMN IF NOT EXISTS student_id VARCHAR(64);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
|
||||
DROP TABLE IF EXISTS portal_course;
|
||||
DROP TABLE IF EXISTS portal_grade;
|
||||
```
|
||||
|
||||
## 主要接口
|
||||
@@ -103,22 +83,11 @@ CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, sem
|
||||
- `GET /api/files/download/{fileId}`
|
||||
- `GET /api/files/download/{fileId}/url`
|
||||
- `DELETE /api/files/{fileId}`
|
||||
- `GET /api/cqu/schedule`
|
||||
- `GET /api/cqu/grades`
|
||||
|
||||
## CQU 配置
|
||||
|
||||
部署到真实环境时修改:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
cqu:
|
||||
base-url: https://your-cqu-api
|
||||
require-login: false
|
||||
mock-enabled: false
|
||||
```
|
||||
|
||||
当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。
|
||||
- `POST /api/transfer/sessions`
|
||||
- `GET /api/transfer/sessions/lookup`
|
||||
- `POST /api/transfer/sessions/{sessionId}/join`
|
||||
- `POST /api/transfer/sessions/{sessionId}/signals`
|
||||
- `GET /api/transfer/sessions/{sessionId}/signals`
|
||||
|
||||
## OSS 直传说明
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS portal_user (
|
||||
email VARCHAR(128) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_school_student_id VARCHAR(64),
|
||||
last_school_semester VARCHAR(64),
|
||||
CONSTRAINT uk_portal_user_username UNIQUE (username),
|
||||
CONSTRAINT uk_portal_user_email UNIQUE (email)
|
||||
);
|
||||
@@ -27,35 +25,5 @@ CREATE TABLE IF NOT EXISTS portal_file (
|
||||
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_course (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
semester VARCHAR(64),
|
||||
student_id VARCHAR(64),
|
||||
teacher VARCHAR(255),
|
||||
classroom VARCHAR(255),
|
||||
day_of_week INT,
|
||||
start_time INT,
|
||||
end_time INT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_portal_course_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_grade (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
grade DOUBLE NOT NULL,
|
||||
semester VARCHAR(64) NOT NULL,
|
||||
student_id VARCHAR(64),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_portal_grade_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_created_at ON portal_user (created_at);
|
||||
CREATE INDEX idx_file_created_at ON portal_file (created_at);
|
||||
CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id);
|
||||
CREATE INDEX idx_course_user_created ON portal_course (user_id, created_at);
|
||||
CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
|
||||
CREATE INDEX idx_grade_user_created ON portal_grade (user_id, created_at);
|
||||
|
||||
@@ -3,9 +3,7 @@ CREATE TABLE IF NOT EXISTS portal_user (
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
email VARCHAR(128) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_school_student_id VARCHAR(64),
|
||||
last_school_semester VARCHAR(64)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_file (
|
||||
@@ -21,33 +19,5 @@ CREATE TABLE IF NOT EXISTS portal_file (
|
||||
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_course (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES portal_user (id),
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
semester VARCHAR(64),
|
||||
student_id VARCHAR(64),
|
||||
teacher VARCHAR(255),
|
||||
classroom VARCHAR(255),
|
||||
day_of_week INTEGER,
|
||||
start_time INTEGER,
|
||||
end_time INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_grade (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES portal_user (id),
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
grade DOUBLE PRECISION NOT NULL,
|
||||
semester VARCHAR(64) NOT NULL,
|
||||
student_id VARCHAR(64),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_user_created ON portal_course (user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_user_created ON portal_grade (user_id, created_at);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.yoyuzh;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import com.yoyuzh.config.CorsProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -13,7 +12,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
@EnableConfigurationProperties({
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CquApiProperties.class,
|
||||
CorsProperties.class,
|
||||
AdminProperties.class
|
||||
})
|
||||
|
||||
@@ -50,13 +50,6 @@ public class AdminController {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@GetMapping("/school-snapshots")
|
||||
public ApiResponse<PageResponse<AdminSchoolSnapshotResponse>> schoolSnapshots(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ApiResponse.success(adminService.listSchoolSnapshots(page, size));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/role")
|
||||
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSchoolSnapshotResponse(
|
||||
Long id,
|
||||
Long userId,
|
||||
String username,
|
||||
String email,
|
||||
String studentId,
|
||||
String semester,
|
||||
long scheduleCount,
|
||||
long gradeCount
|
||||
) {
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import com.yoyuzh.auth.RefreshTokenService;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.cqu.CourseRepository;
|
||||
import com.yoyuzh.cqu.GradeRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
@@ -31,8 +29,6 @@ public class AdminService {
|
||||
private final UserRepository userRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileService fileService;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
@@ -40,8 +36,7 @@ public class AdminService {
|
||||
public AdminSummaryResponse getSummary() {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
userRepository.countByLastSchoolStudentIdIsNotNull()
|
||||
storedFileRepository.count()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,16 +64,6 @@ public class AdminService {
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<AdminSchoolSnapshotResponse> listSchoolSnapshots(int page, int size) {
|
||||
Page<User> result = userRepository.findByLastSchoolStudentIdIsNotNull(
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
List<AdminSchoolSnapshotResponse> items = result.getContent().stream()
|
||||
.map(this::toSchoolSnapshotResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteFile(Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
@@ -126,8 +111,6 @@ public class AdminService {
|
||||
user.getEmail(),
|
||||
user.getPhoneNumber(),
|
||||
user.getCreatedAt(),
|
||||
user.getLastSchoolStudentId(),
|
||||
user.getLastSchoolSemester(),
|
||||
user.getRole(),
|
||||
user.isBanned()
|
||||
);
|
||||
@@ -149,28 +132,6 @@ public class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
private AdminSchoolSnapshotResponse toSchoolSnapshotResponse(User user) {
|
||||
String studentId = user.getLastSchoolStudentId();
|
||||
String semester = user.getLastSchoolSemester();
|
||||
long scheduleCount = studentId == null || semester == null
|
||||
? 0
|
||||
: courseRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
long gradeCount = studentId == null || semester == null
|
||||
? 0
|
||||
: gradeRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
|
||||
return new AdminSchoolSnapshotResponse(
|
||||
user.getId(),
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
studentId,
|
||||
semester,
|
||||
scheduleCount,
|
||||
gradeCount
|
||||
);
|
||||
}
|
||||
|
||||
private User getRequiredUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles,
|
||||
long usersWithSchoolCache
|
||||
long totalFiles
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ public record AdminUserResponse(
|
||||
String email,
|
||||
String phoneNumber,
|
||||
LocalDateTime createdAt,
|
||||
String lastSchoolStudentId,
|
||||
String lastSchoolSemester,
|
||||
UserRole role,
|
||||
boolean banned
|
||||
) {
|
||||
|
||||
@@ -40,12 +40,6 @@ public class User {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "last_school_student_id", length = 64)
|
||||
private String lastSchoolStudentId;
|
||||
|
||||
@Column(name = "last_school_semester", length = 64)
|
||||
private String lastSchoolSemester;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 64)
|
||||
private String displayName;
|
||||
|
||||
@@ -135,22 +129,6 @@ public class User {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getLastSchoolStudentId() {
|
||||
return lastSchoolStudentId;
|
||||
}
|
||||
|
||||
public void setLastSchoolStudentId(String lastSchoolStudentId) {
|
||||
this.lastSchoolStudentId = lastSchoolStudentId;
|
||||
}
|
||||
|
||||
public String getLastSchoolSemester() {
|
||||
return lastSchoolSemester;
|
||||
}
|
||||
|
||||
public void setLastSchoolSemester(String lastSchoolSemester) {
|
||||
this.lastSchoolSemester = lastSchoolSemester;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
long countByLastSchoolStudentIdIsNotNull();
|
||||
|
||||
Page<User> findByLastSchoolStudentIdIsNotNull(Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select u from User u
|
||||
where (:query is null or :query = ''
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.cqu")
|
||||
public class CquApiProperties {
|
||||
|
||||
private String baseUrl = "https://example-cqu-api.local";
|
||||
private boolean requireLogin = false;
|
||||
private boolean mockEnabled = false;
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public boolean isRequireLogin() {
|
||||
return requireLogin;
|
||||
}
|
||||
|
||||
public void setRequireLogin(boolean requireLogin) {
|
||||
this.requireLogin = requireLogin;
|
||||
}
|
||||
|
||||
public boolean isMockEnabled() {
|
||||
return mockEnabled;
|
||||
}
|
||||
|
||||
public void setMockEnabled(boolean mockEnabled) {
|
||||
this.mockEnabled = mockEnabled;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
@@ -47,9 +48,13 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/transfer/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/admin/**")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
|
||||
.requestMatchers("/api/files/**", "/api/user/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_course", indexes = {
|
||||
@Index(name = "idx_course_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_course_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Course {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(length = 255)
|
||||
private String teacher;
|
||||
|
||||
@Column(length = 255)
|
||||
private String classroom;
|
||||
|
||||
@Column(name = "day_of_week")
|
||||
private Integer dayOfWeek;
|
||||
|
||||
@Column(name = "start_time")
|
||||
private Integer startTime;
|
||||
|
||||
@Column(name = "end_time")
|
||||
private Integer endTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public String getTeacher() {
|
||||
return teacher;
|
||||
}
|
||||
|
||||
public void setTeacher(String teacher) {
|
||||
this.teacher = teacher;
|
||||
}
|
||||
|
||||
public String getClassroom() {
|
||||
return classroom;
|
||||
}
|
||||
|
||||
public void setClassroom(String classroom) {
|
||||
this.classroom = classroom;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public Integer getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Integer startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Integer getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Integer endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CourseRepository extends JpaRepository<Course, Long> {
|
||||
List<Course> findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester);
|
||||
|
||||
Optional<Course> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record CourseResponse(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CquApiClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final CquApiProperties properties;
|
||||
|
||||
public List<Map<String, Object>> fetchSchedule(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createSchedule(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/schedule?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> fetchGrades(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createGrades(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/grades?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cqu")
|
||||
@RequiredArgsConstructor
|
||||
public class CquController {
|
||||
|
||||
private final CquDataService cquDataService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "获取课表")
|
||||
@GetMapping("/schedule")
|
||||
public ApiResponse<List<CourseResponse>> schedule(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId,
|
||||
@RequestParam(defaultValue = "false") boolean refresh) {
|
||||
return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId, refresh));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取成绩")
|
||||
@GetMapping("/grades")
|
||||
public ApiResponse<List<GradeResponse>> grades(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId,
|
||||
@RequestParam(defaultValue = "false") boolean refresh) {
|
||||
return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId, refresh));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取最近一次教务数据")
|
||||
@GetMapping("/latest")
|
||||
public ApiResponse<LatestSchoolDataResponse> latest(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return ApiResponse.success(cquDataService.getLatest(resolveUser(userDetails)));
|
||||
}
|
||||
|
||||
private User resolveUser(UserDetails userDetails) {
|
||||
return userDetails == null ? null : userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CquDataService {
|
||||
|
||||
private final CquApiClient cquApiClient;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CquApiProperties cquApiProperties;
|
||||
|
||||
@Transactional
|
||||
public List<CourseResponse> getSchedule(User user, String semester, String studentId) {
|
||||
return getSchedule(user, semester, studentId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<CourseResponse> getSchedule(User user, String semester, String studentId, boolean refresh) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user != null && !refresh) {
|
||||
List<CourseResponse> stored = readSavedSchedule(user.getId(), studentId, semester);
|
||||
if (!stored.isEmpty()) {
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream()
|
||||
.map(this::toCourseResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveCourses(user, semester, studentId, responses);
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedSchedule(user.getId(), studentId, semester);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<GradeResponse> getGrades(User user, String semester, String studentId) {
|
||||
return getGrades(user, semester, studentId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<GradeResponse> getGrades(User user, String semester, String studentId, boolean refresh) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user != null && !refresh
|
||||
&& gradeRepository.existsByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester)) {
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedGrades(user.getId(), studentId);
|
||||
}
|
||||
|
||||
List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream()
|
||||
.map(this::toGradeResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveGrades(user, semester, studentId, responses);
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedGrades(user.getId(), studentId);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LatestSchoolDataResponse getLatest(User user) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
QueryContext context = resolveLatestContext(user);
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<CourseResponse> schedule = readSavedSchedule(user.getId(), context.studentId(), context.semester());
|
||||
List<GradeResponse> grades = readSavedGrades(user.getId(), context.studentId());
|
||||
if (schedule.isEmpty() && grades.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LatestSchoolDataResponse(context.studentId(), context.semester(), schedule, grades);
|
||||
}
|
||||
|
||||
private void requireLoginIfNecessary(User user) {
|
||||
if (cquApiProperties.isRequireLogin() && user == null) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "该接口需要登录后访问");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveCourses(User user, String semester, String studentId, List<CourseResponse> responses) {
|
||||
courseRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
courseRepository.saveAll(responses.stream().map(item -> {
|
||||
Course course = new Course();
|
||||
course.setUser(user);
|
||||
course.setCourseName(item.courseName());
|
||||
course.setSemester(semester);
|
||||
course.setStudentId(studentId);
|
||||
course.setTeacher(item.teacher());
|
||||
course.setClassroom(item.classroom());
|
||||
course.setDayOfWeek(item.dayOfWeek());
|
||||
course.setStartTime(item.startTime());
|
||||
course.setEndTime(item.endTime());
|
||||
return course;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveGrades(User user, String semester, String studentId, List<GradeResponse> responses) {
|
||||
gradeRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
gradeRepository.saveAll(responses.stream().map(item -> {
|
||||
Grade grade = new Grade();
|
||||
grade.setUser(user);
|
||||
grade.setCourseName(item.courseName());
|
||||
grade.setGrade(item.grade());
|
||||
grade.setSemester(item.semester() == null ? semester : item.semester());
|
||||
grade.setStudentId(studentId);
|
||||
return grade;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
private List<CourseResponse> readSavedSchedule(Long userId, String studentId, String semester) {
|
||||
return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(
|
||||
userId, studentId, semester)
|
||||
.stream()
|
||||
.map(item -> new CourseResponse(
|
||||
item.getCourseName(),
|
||||
item.getTeacher(),
|
||||
item.getClassroom(),
|
||||
item.getDayOfWeek(),
|
||||
item.getStartTime(),
|
||||
item.getEndTime()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<GradeResponse> readSavedGrades(Long userId, String studentId) {
|
||||
return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(userId, studentId)
|
||||
.stream()
|
||||
.map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void rememberLastSchoolQuery(User user, String studentId, String semester) {
|
||||
boolean changed = false;
|
||||
if (!semester.equals(user.getLastSchoolSemester())) {
|
||||
user.setLastSchoolSemester(semester);
|
||||
changed = true;
|
||||
}
|
||||
if (!studentId.equals(user.getLastSchoolStudentId())) {
|
||||
user.setLastSchoolStudentId(studentId);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
private QueryContext resolveLatestContext(User user) {
|
||||
if (hasText(user.getLastSchoolStudentId()) && hasText(user.getLastSchoolSemester())) {
|
||||
return new QueryContext(user.getLastSchoolStudentId(), user.getLastSchoolSemester());
|
||||
}
|
||||
|
||||
Optional<Course> latestCourse = courseRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
Optional<Grade> latestGrade = gradeRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
if (latestCourse.isEmpty() && latestGrade.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
QueryContext context;
|
||||
if (latestGrade.isEmpty()) {
|
||||
context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester());
|
||||
} else if (latestCourse.isEmpty()) {
|
||||
context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester());
|
||||
} else if (latestCourse.get().getCreatedAt().isAfter(latestGrade.get().getCreatedAt())) {
|
||||
context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester());
|
||||
} else {
|
||||
context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester());
|
||||
}
|
||||
|
||||
if (hasText(context.studentId()) && hasText(context.semester())) {
|
||||
user.setLastSchoolStudentId(context.studentId());
|
||||
user.setLastSchoolSemester(context.semester());
|
||||
userRepository.save(user);
|
||||
return context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private CourseResponse toCourseResponse(Map<String, Object> source) {
|
||||
return new CourseResponse(
|
||||
stringValue(source, "courseName"),
|
||||
stringValue(source, "teacher"),
|
||||
stringValue(source, "classroom"),
|
||||
intValue(source, "dayOfWeek"),
|
||||
intValue(source, "startTime"),
|
||||
intValue(source, "endTime"));
|
||||
}
|
||||
|
||||
private GradeResponse toGradeResponse(Map<String, Object> source) {
|
||||
return new GradeResponse(
|
||||
stringValue(source, "courseName"),
|
||||
doubleValue(source, "grade"),
|
||||
stringValue(source, "semester"));
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Integer.parseInt(value.toString());
|
||||
}
|
||||
|
||||
private Double doubleValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Double.parseDouble(value.toString());
|
||||
}
|
||||
|
||||
private record QueryContext(String studentId, String semester) {
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CquMockDataFactory {
|
||||
|
||||
private CquMockDataFactory() {
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createSchedule(String semester, String studentId) {
|
||||
StudentProfile profile = StudentProfile.fromStudentId(studentId);
|
||||
return profile.schedule().stream()
|
||||
.map(item -> Map.<String, Object>of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", item.courseName(),
|
||||
"teacher", item.teacher(),
|
||||
"classroom", item.classroom(),
|
||||
"dayOfWeek", item.dayOfWeek(),
|
||||
"startTime", item.startTime(),
|
||||
"endTime", item.endTime()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createGrades(String semester, String studentId) {
|
||||
StudentProfile profile = StudentProfile.fromStudentId(studentId);
|
||||
return profile.grades().stream()
|
||||
.map(item -> Map.<String, Object>of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", item.courseName(),
|
||||
"grade", item.score()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ScheduleEntry(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
|
||||
private record GradeEntry(String courseName, Double score) {
|
||||
}
|
||||
|
||||
private record StudentProfile(
|
||||
List<ScheduleEntry> schedule,
|
||||
List<GradeEntry> grades
|
||||
) {
|
||||
private static StudentProfile fromStudentId(String studentId) {
|
||||
return switch (studentId) {
|
||||
case "2023123456" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2),
|
||||
new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4),
|
||||
new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||
new GradeEntry("计算机网络", 88.5),
|
||||
new GradeEntry("软件工程", 90.0)
|
||||
)
|
||||
);
|
||||
case "2022456789" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("数据挖掘", "陈老师", "A1408", 2, 1, 2),
|
||||
new ScheduleEntry("机器学习基础", "赵老师", "B2201", 4, 3, 4),
|
||||
new ScheduleEntry("信息检索", "孙老师", "C1205", 5, 7, 8)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("数据挖掘", 94.0),
|
||||
new GradeEntry("机器学习基础", 91.0),
|
||||
new GradeEntry("信息检索", 89.0)
|
||||
)
|
||||
);
|
||||
case "2021789012" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("交互设计", "刘老师", "艺设楼201", 1, 3, 4),
|
||||
new ScheduleEntry("视觉传达专题", "黄老师", "艺设楼305", 3, 5, 6),
|
||||
new ScheduleEntry("数字媒体项目实践", "许老师", "创意工坊101", 4, 7, 8)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("交互设计", 96.0),
|
||||
new GradeEntry("视觉传达专题", 93.0),
|
||||
new GradeEntry("数字媒体项目实践", 97.0)
|
||||
)
|
||||
);
|
||||
default -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2),
|
||||
new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4),
|
||||
new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||
new GradeEntry("计算机网络", 88.5),
|
||||
new GradeEntry("软件工程", 90.0)
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_grade", indexes = {
|
||||
@Index(name = "idx_grade_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_grade_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Grade {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Double grade;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Double getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(Double grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GradeRepository extends JpaRepository<Grade, Long> {
|
||||
List<Grade> findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId);
|
||||
|
||||
boolean existsByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
Optional<Grade> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record GradeResponse(
|
||||
String courseName,
|
||||
Double grade,
|
||||
String semester
|
||||
) {
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LatestSchoolDataResponse(
|
||||
String studentId,
|
||||
String semester,
|
||||
List<CourseResponse> schedule,
|
||||
List<GradeResponse> grades
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CopyFileRequest(
|
||||
@NotBlank(message = "目标路径不能为空")
|
||||
String path
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CreateFileShareLinkResponse(
|
||||
String token,
|
||||
String filename,
|
||||
long size,
|
||||
String contentType,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -108,6 +108,53 @@ public class FileController {
|
||||
fileService.rename(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.filename()));
|
||||
}
|
||||
|
||||
@Operation(summary = "移动文件")
|
||||
@PatchMapping("/{fileId}/move")
|
||||
public ApiResponse<FileMetadataResponse> move(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId,
|
||||
@Valid @RequestBody MoveFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.move(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.path()));
|
||||
}
|
||||
|
||||
@Operation(summary = "复制文件")
|
||||
@PostMapping("/{fileId}/copy")
|
||||
public ApiResponse<FileMetadataResponse> copy(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId,
|
||||
@Valid @RequestBody CopyFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.copy(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.path()));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建分享链接")
|
||||
@PostMapping("/{fileId}/share-links")
|
||||
public ApiResponse<CreateFileShareLinkResponse> createShareLink(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId) {
|
||||
return ApiResponse.success(
|
||||
fileService.createShareLink(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId)
|
||||
);
|
||||
}
|
||||
|
||||
@Operation(summary = "查看分享详情")
|
||||
@GetMapping("/share-links/{token}")
|
||||
public ApiResponse<FileShareDetailsResponse> getShareDetails(@PathVariable String token) {
|
||||
return ApiResponse.success(fileService.getShareDetails(token));
|
||||
}
|
||||
|
||||
@Operation(summary = "导入共享文件")
|
||||
@PostMapping("/share-links/{token}/import")
|
||||
public ApiResponse<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String token,
|
||||
@Valid @RequestBody ImportSharedFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.importSharedFile(
|
||||
userDetailsService.loadDomainUser(userDetails.getUsername()),
|
||||
token,
|
||||
request.path()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件")
|
||||
@DeleteMapping("/{fileId}")
|
||||
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,
|
||||
|
||||
@@ -22,10 +22,12 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -35,13 +37,16 @@ public class FileService {
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final FileShareLinkRepository fileShareLinkRepository;
|
||||
private final long maxFileSize;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileShareLinkRepository fileShareLinkRepository,
|
||||
FileStorageProperties properties) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
}
|
||||
|
||||
@@ -207,6 +212,105 @@ public class FileService {
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse move(User user, Long fileId, String nextPath) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "移动");
|
||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||
if (normalizedTargetPath.equals(storedFile.getPath())) {
|
||||
return toResponse(storedFile);
|
||||
}
|
||||
|
||||
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (storedFile.isDirectory()) {
|
||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||
String newLogicalPath = "/".equals(normalizedTargetPath)
|
||||
? "/" + storedFile.getFilename()
|
||||
: normalizedTargetPath + "/" + storedFile.getFilename();
|
||||
if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "不能移动到当前目录或其子目录");
|
||||
}
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
||||
for (StoredFile descendant : descendants) {
|
||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||
descendant.setPath(newLogicalPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
descendant.setPath(newLogicalPath + descendant.getPath().substring(oldLogicalPath.length()));
|
||||
}
|
||||
if (!descendants.isEmpty()) {
|
||||
storedFileRepository.saveAll(descendants);
|
||||
}
|
||||
} else {
|
||||
fileContentStorage.moveFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
}
|
||||
|
||||
storedFile.setPath(normalizedTargetPath);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse copy(User user, Long fileId, String nextPath) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "复制");
|
||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (!storedFile.isDirectory()) {
|
||||
fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath)));
|
||||
}
|
||||
|
||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||
String newLogicalPath = buildTargetLogicalPath(normalizedTargetPath, storedFile.getFilename());
|
||||
if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "不能复制到当前目录或其子目录");
|
||||
}
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
List<StoredFile> copiedEntries = new ArrayList<>();
|
||||
|
||||
fileContentStorage.ensureDirectory(user.getId(), newLogicalPath);
|
||||
StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath);
|
||||
copiedEntries.add(copiedRoot);
|
||||
|
||||
descendants.stream()
|
||||
.sorted(Comparator
|
||||
.comparingInt((StoredFile descendant) -> descendant.getPath().length())
|
||||
.thenComparing(descendant -> descendant.isDirectory() ? 0 : 1)
|
||||
.thenComparing(StoredFile::getFilename))
|
||||
.forEach(descendant -> {
|
||||
String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (descendant.isDirectory()) {
|
||||
fileContentStorage.ensureDirectory(user.getId(), buildTargetLogicalPath(copiedPath, descendant.getFilename()));
|
||||
} else {
|
||||
fileContentStorage.copyFile(user.getId(), descendant.getPath(), copiedPath, descendant.getStorageName());
|
||||
}
|
||||
copiedEntries.add(copyStoredFile(descendant, copiedPath));
|
||||
});
|
||||
|
||||
StoredFile savedRoot = null;
|
||||
for (StoredFile copiedEntry : copiedEntries) {
|
||||
StoredFile savedEntry = storedFileRepository.save(copiedEntry);
|
||||
if (savedRoot == null) {
|
||||
savedRoot = savedEntry;
|
||||
}
|
||||
}
|
||||
return toResponse(savedRoot == null ? copiedRoot : savedRoot);
|
||||
}
|
||||
|
||||
public ResponseEntity<?> download(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
|
||||
if (storedFile.isDirectory()) {
|
||||
@@ -249,6 +353,78 @@ public class FileService {
|
||||
return new DownloadUrlResponse("/api/files/download/" + storedFile.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CreateFileShareLinkResponse createShareLink(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "分享");
|
||||
if (storedFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接");
|
||||
}
|
||||
|
||||
FileShareLink shareLink = new FileShareLink();
|
||||
shareLink.setOwner(user);
|
||||
shareLink.setFile(storedFile);
|
||||
shareLink.setToken(UUID.randomUUID().toString().replace("-", ""));
|
||||
FileShareLink saved = fileShareLinkRepository.save(shareLink);
|
||||
|
||||
return new CreateFileShareLinkResponse(
|
||||
saved.getToken(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
saved.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public FileShareDetailsResponse getShareDetails(String token) {
|
||||
FileShareLink shareLink = getShareLink(token);
|
||||
StoredFile storedFile = shareLink.getFile();
|
||||
return new FileShareDetailsResponse(
|
||||
shareLink.getToken(),
|
||||
shareLink.getOwner().getUsername(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
storedFile.isDirectory(),
|
||||
shareLink.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse importSharedFile(User recipient, String token, String path) {
|
||||
FileShareLink shareLink = getShareLink(token);
|
||||
StoredFile sourceFile = shareLink.getFile();
|
||||
if (sourceFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
|
||||
}
|
||||
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
String filename = normalizeLeafName(sourceFile.getFilename());
|
||||
validateUpload(recipient.getId(), normalizedPath, filename, sourceFile.getSize());
|
||||
ensureDirectoryHierarchy(recipient, normalizedPath);
|
||||
|
||||
byte[] content = fileContentStorage.readFile(
|
||||
sourceFile.getUser().getId(),
|
||||
sourceFile.getPath(),
|
||||
sourceFile.getStorageName()
|
||||
);
|
||||
fileContentStorage.storeImportedFile(
|
||||
recipient.getId(),
|
||||
normalizedPath,
|
||||
filename,
|
||||
sourceFile.getContentType(),
|
||||
content
|
||||
);
|
||||
|
||||
return saveFileMetadata(
|
||||
recipient,
|
||||
normalizedPath,
|
||||
filename,
|
||||
filename,
|
||||
sourceFile.getContentType(),
|
||||
sourceFile.getSize()
|
||||
);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
||||
String logicalPath = buildLogicalPath(directory);
|
||||
String archiveName = directory.getFilename() + ".zip";
|
||||
@@ -304,6 +480,11 @@ public class FileService {
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
private FileShareLink getShareLink(String token) {
|
||||
return fileShareLinkRepository.findByToken(token)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在"));
|
||||
}
|
||||
|
||||
private StoredFile getOwnedFile(User user, Long fileId, String action) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
@@ -353,6 +534,23 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureExistingDirectoryPath(Long userId, String normalizedPath) {
|
||||
if ("/".equals(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] segments = normalizedPath.substring(1).split("/");
|
||||
String currentPath = "/";
|
||||
for (String segment : segments) {
|
||||
StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在"));
|
||||
if (!directory.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
|
||||
}
|
||||
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUploadFilename(String originalFilename) {
|
||||
String filename = StringUtils.cleanPath(originalFilename);
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
@@ -406,6 +604,31 @@ public class FileService {
|
||||
: storedFile.getPath() + "/" + storedFile.getFilename();
|
||||
}
|
||||
|
||||
private String buildTargetLogicalPath(String normalizedTargetPath, String filename) {
|
||||
return "/".equals(normalizedTargetPath)
|
||||
? "/" + filename
|
||||
: normalizedTargetPath + "/" + filename;
|
||||
}
|
||||
|
||||
private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) {
|
||||
if (currentPath.equals(oldLogicalPath)) {
|
||||
return newLogicalPath;
|
||||
}
|
||||
return newLogicalPath + currentPath.substring(oldLogicalPath.length());
|
||||
}
|
||||
|
||||
private StoredFile copyStoredFile(StoredFile source, String nextPath) {
|
||||
StoredFile copiedFile = new StoredFile();
|
||||
copiedFile.setUser(source.getUser());
|
||||
copiedFile.setFilename(source.getFilename());
|
||||
copiedFile.setPath(nextPath);
|
||||
copiedFile.setStorageName(source.getStorageName());
|
||||
copiedFile.setContentType(source.getContentType());
|
||||
copiedFile.setSize(source.getSize());
|
||||
copiedFile.setDirectory(source.isDirectory());
|
||||
return copiedFile;
|
||||
}
|
||||
|
||||
private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
|
||||
StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/');
|
||||
if (!storedFile.getPath().equals(rootLogicalPath)) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record FileShareDetailsResponse(
|
||||
String token,
|
||||
String ownerUsername,
|
||||
String filename,
|
||||
long size,
|
||||
String contentType,
|
||||
boolean directory,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
89
backend/src/main/java/com/yoyuzh/files/FileShareLink.java
Normal file
89
backend/src/main/java/com/yoyuzh/files/FileShareLink.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file_share_link", indexes = {
|
||||
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
|
||||
@Index(name = "idx_file_share_created_at", columnList = "created_at")
|
||||
})
|
||||
public class FileShareLink {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "file_id", nullable = false)
|
||||
private StoredFile file;
|
||||
|
||||
@Column(nullable = false, length = 96, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(User owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public StoredFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public void setFile(StoredFile file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
|
||||
Optional<FileShareLink> findByToken(String token);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ImportSharedFileRequest(@NotBlank String path) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MoveFileRequest(
|
||||
@NotBlank(message = "目标路径不能为空")
|
||||
String path
|
||||
) {
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
@@ -38,6 +39,14 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path and f.filename = :filename
|
||||
""")
|
||||
Optional<StoredFile> findByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CreateTransferSessionRequest(
|
||||
@NotEmpty(message = "至少选择一个文件")
|
||||
List<@Valid TransferFileItem> files
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LookupTransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PollTransferSignalsResponse(
|
||||
List<TransferSignalEnvelope> items,
|
||||
long nextCursor
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/transfer")
|
||||
@RequiredArgsConstructor
|
||||
public class TransferController {
|
||||
|
||||
private final TransferService transferService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "创建快传会话")
|
||||
@PostMapping("/sessions")
|
||||
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateTransferSessionRequest request) {
|
||||
requireAuthenticatedUser(userDetails);
|
||||
userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiResponse.success(transferService.createSession(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "通过取件码查找快传会话")
|
||||
@GetMapping("/sessions/lookup")
|
||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
|
||||
return ApiResponse.success(transferService.lookupSession(pickupCode));
|
||||
}
|
||||
|
||||
@Operation(summary = "加入快传会话")
|
||||
@PostMapping("/sessions/{sessionId}/join")
|
||||
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
|
||||
return ApiResponse.success(transferService.joinSession(sessionId));
|
||||
}
|
||||
|
||||
@Operation(summary = "提交快传信令")
|
||||
@PostMapping("/sessions/{sessionId}/signals")
|
||||
public ApiResponse<Void> postSignal(@PathVariable String sessionId,
|
||||
@RequestParam String role,
|
||||
@Valid @RequestBody TransferSignalRequest request) {
|
||||
transferService.postSignal(sessionId, role, request);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "轮询快传信令")
|
||||
@GetMapping("/sessions/{sessionId}/signals")
|
||||
public ApiResponse<PollTransferSignalsResponse> pollSignals(@PathVariable String sessionId,
|
||||
@RequestParam String role,
|
||||
@RequestParam(defaultValue = "0") long after) {
|
||||
return ApiResponse.success(transferService.pollSignals(sessionId, role, after));
|
||||
}
|
||||
|
||||
private void requireAuthenticatedUser(UserDetails userDetails) {
|
||||
if (userDetails == null) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户未登录");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TransferFileItem(
|
||||
@NotBlank(message = "文件名不能为空")
|
||||
String name,
|
||||
@Min(value = 0, message = "文件大小不能为负数")
|
||||
long size,
|
||||
String contentType
|
||||
) {
|
||||
}
|
||||
21
backend/src/main/java/com/yoyuzh/transfer/TransferRole.java
Normal file
21
backend/src/main/java/com/yoyuzh/transfer/TransferRole.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
enum TransferRole {
|
||||
SENDER,
|
||||
RECEIVER;
|
||||
|
||||
static TransferRole from(String role) {
|
||||
String normalized = Objects.requireNonNullElse(role, "").trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "sender" -> SENDER;
|
||||
case "receiver" -> RECEIVER;
|
||||
default -> throw new BusinessException(ErrorCode.UNKNOWN, "不支持的传输角色");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class TransferService {
|
||||
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private final TransferSessionStore sessionStore;
|
||||
|
||||
public TransferService(TransferSessionStore sessionStore) {
|
||||
this.sessionStore = sessionStore;
|
||||
}
|
||||
|
||||
public TransferSessionResponse createSession(CreateTransferSessionRequest request) {
|
||||
pruneExpiredSessions();
|
||||
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
String pickupCode = sessionStore.nextPickupCode();
|
||||
Instant expiresAt = Instant.now().plus(SESSION_TTL);
|
||||
List<TransferFileItem> files = request.files().stream()
|
||||
.map(file -> new TransferFileItem(file.name(), file.size(), normalizeContentType(file.contentType())))
|
||||
.toList();
|
||||
|
||||
TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files);
|
||||
sessionStore.save(session);
|
||||
return session.toSessionResponse();
|
||||
}
|
||||
|
||||
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
||||
pruneExpiredSessions();
|
||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
||||
TransferSession session = sessionStore.findByPickupCode(normalizedPickupCode)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||
return session.toLookupResponse();
|
||||
}
|
||||
|
||||
public TransferSessionResponse joinSession(String sessionId) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
session.markReceiverJoined();
|
||||
return session.toSessionResponse();
|
||||
}
|
||||
|
||||
public void postSignal(String sessionId, String role, TransferSignalRequest request) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim());
|
||||
}
|
||||
|
||||
public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
||||
}
|
||||
|
||||
private TransferSession getRequiredSession(String sessionId) {
|
||||
TransferSession session = sessionStore.findById(sessionId).orElse(null);
|
||||
if (session == null || session.isExpired(Instant.now())) {
|
||||
if (session != null) {
|
||||
sessionStore.remove(session);
|
||||
}
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private void pruneExpiredSessions() {
|
||||
sessionStore.pruneExpired(Instant.now());
|
||||
}
|
||||
|
||||
private String normalizePickupCode(String pickupCode) {
|
||||
String normalized = Objects.requireNonNullElse(pickupCode, "").replaceAll("\\D", "");
|
||||
if (normalized.length() != 6) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "取件码格式不正确");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeContentType(String contentType) {
|
||||
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
||||
return normalized.isEmpty() ? "application/octet-stream" : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class TransferSession {
|
||||
|
||||
private final String sessionId;
|
||||
private final String pickupCode;
|
||||
private final Instant expiresAt;
|
||||
private final List<TransferFileItem> files;
|
||||
private final List<TransferSignalEnvelope> senderQueue = new ArrayList<>();
|
||||
private final List<TransferSignalEnvelope> receiverQueue = new ArrayList<>();
|
||||
private boolean receiverJoined;
|
||||
private long nextSenderCursor = 1;
|
||||
private long nextReceiverCursor = 1;
|
||||
|
||||
TransferSession(String sessionId, String pickupCode, Instant expiresAt, List<TransferFileItem> files) {
|
||||
this.sessionId = sessionId;
|
||||
this.pickupCode = pickupCode;
|
||||
this.expiresAt = expiresAt;
|
||||
this.files = List.copyOf(files);
|
||||
}
|
||||
|
||||
synchronized TransferSessionResponse toSessionResponse() {
|
||||
return new TransferSessionResponse(sessionId, pickupCode, expiresAt, files);
|
||||
}
|
||||
|
||||
synchronized LookupTransferSessionResponse toLookupResponse() {
|
||||
return new LookupTransferSessionResponse(sessionId, pickupCode, expiresAt);
|
||||
}
|
||||
|
||||
synchronized void markReceiverJoined() {
|
||||
if (receiverJoined) {
|
||||
return;
|
||||
}
|
||||
|
||||
receiverJoined = true;
|
||||
senderQueue.add(new TransferSignalEnvelope(nextSenderCursor++, "peer-joined", "{}"));
|
||||
}
|
||||
|
||||
synchronized void enqueue(TransferRole sourceRole, String type, String payload) {
|
||||
if (sourceRole == TransferRole.SENDER) {
|
||||
receiverQueue.add(new TransferSignalEnvelope(nextReceiverCursor++, type, payload));
|
||||
return;
|
||||
}
|
||||
|
||||
senderQueue.add(new TransferSignalEnvelope(nextSenderCursor++, type, payload));
|
||||
}
|
||||
|
||||
synchronized PollTransferSignalsResponse poll(TransferRole role, long after) {
|
||||
List<TransferSignalEnvelope> queue = role == TransferRole.SENDER ? senderQueue : receiverQueue;
|
||||
List<TransferSignalEnvelope> items = queue.stream()
|
||||
.filter(item -> item.cursor() > after)
|
||||
.toList();
|
||||
long nextCursor = items.isEmpty() ? after : items.get(items.size() - 1).cursor();
|
||||
return new PollTransferSignalsResponse(items, nextCursor);
|
||||
}
|
||||
|
||||
boolean isExpired(Instant now) {
|
||||
return expiresAt.isBefore(now);
|
||||
}
|
||||
|
||||
String sessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
String pickupCode() {
|
||||
return pickupCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record TransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
Instant expiresAt,
|
||||
List<TransferFileItem> files
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Component
|
||||
public class TransferSessionStore {
|
||||
|
||||
private final Map<String, TransferSession> sessionsById = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> sessionIdsByPickupCode = new ConcurrentHashMap<>();
|
||||
|
||||
public void save(TransferSession session) {
|
||||
sessionsById.put(session.sessionId(), session);
|
||||
sessionIdsByPickupCode.put(session.pickupCode(), session.sessionId());
|
||||
}
|
||||
|
||||
public Optional<TransferSession> findById(String sessionId) {
|
||||
return Optional.ofNullable(sessionsById.get(sessionId));
|
||||
}
|
||||
|
||||
public Optional<TransferSession> findByPickupCode(String pickupCode) {
|
||||
String sessionId = sessionIdsByPickupCode.get(pickupCode);
|
||||
if (sessionId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return findById(sessionId);
|
||||
}
|
||||
|
||||
public void remove(TransferSession session) {
|
||||
sessionsById.remove(session.sessionId(), session);
|
||||
sessionIdsByPickupCode.remove(session.pickupCode(), session.sessionId());
|
||||
}
|
||||
|
||||
public void pruneExpired(Instant now) {
|
||||
for (TransferSession session : sessionsById.values()) {
|
||||
if (session.isExpired(now)) {
|
||||
remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String nextPickupCode() {
|
||||
String pickupCode;
|
||||
do {
|
||||
pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
|
||||
} while (sessionIdsByPickupCode.containsKey(pickupCode));
|
||||
return pickupCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
public record TransferSignalEnvelope(
|
||||
long cursor,
|
||||
String type,
|
||||
String payload
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TransferSignalRequest(
|
||||
@NotBlank(message = "信令类型不能为空")
|
||||
String type,
|
||||
@NotBlank(message = "信令内容不能为空")
|
||||
String payload
|
||||
) {
|
||||
}
|
||||
@@ -17,5 +17,3 @@ app:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
cqu:
|
||||
mock-enabled: true
|
||||
|
||||
@@ -32,10 +32,6 @@ app:
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
cqu:
|
||||
base-url: https://example-cqu-api.local
|
||||
require-login: true
|
||||
mock-enabled: false
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
|
||||
@@ -33,9 +33,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.admin.usernames=admin",
|
||||
"app.storage.root-dir=./target/test-storage-admin",
|
||||
"app.cqu.require-login=true",
|
||||
"app.cqu.mock-enabled=false"
|
||||
"app.storage.root-dir=./target/test-storage-admin"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@@ -66,8 +64,6 @@ class AdminControllerIntegrationTest {
|
||||
portalUser.setPhoneNumber("13800138000");
|
||||
portalUser.setPasswordHash("encoded-password");
|
||||
portalUser.setCreatedAt(LocalDateTime.now());
|
||||
portalUser.setLastSchoolStudentId("20230001");
|
||||
portalUser.setLastSchoolSemester("2025-2026-1");
|
||||
portalUser = userRepository.save(portalUser);
|
||||
|
||||
secondaryUser = new User();
|
||||
@@ -109,15 +105,13 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
|
||||
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
|
||||
.andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001"))
|
||||
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
|
||||
.andExpect(jsonPath("$.data.items[0].banned").value(false));
|
||||
|
||||
mockMvc.perform(get("/api/admin/summary"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.totalUsers").value(2))
|
||||
.andExpect(jsonPath("$.data.totalFiles").value(2))
|
||||
.andExpect(jsonPath("$.data.usersWithSchoolCache").value(1));
|
||||
.andExpect(jsonPath("$.data.totalFiles").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -28,9 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-refresh",
|
||||
"app.cqu.require-login=true",
|
||||
"app.cqu.mock-enabled=false"
|
||||
"app.storage.root-dir=./target/test-storage-refresh"
|
||||
}
|
||||
)
|
||||
class RefreshTokenServiceIntegrationTest {
|
||||
|
||||
@@ -28,4 +28,5 @@ class SecurityConfigTest {
|
||||
assertThat(configuration).isNotNull();
|
||||
assertThat(configuration.getAllowedMethods()).contains("PATCH");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CquDataServiceTest {
|
||||
|
||||
@Mock
|
||||
private CquApiClient cquApiClient;
|
||||
|
||||
@Mock
|
||||
private CourseRepository courseRepository;
|
||||
|
||||
@Mock
|
||||
private GradeRepository gradeRepository;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CquDataService cquDataService;
|
||||
|
||||
@Test
|
||||
void shouldNormalizeScheduleFromRemoteApi() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(false);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
|
||||
when(cquApiClient.fetchSchedule("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
|
||||
"courseName", "Java",
|
||||
"teacher", "Zhang",
|
||||
"classroom", "A101",
|
||||
"dayOfWeek", 1,
|
||||
"startTime", 1,
|
||||
"endTime", 2
|
||||
)));
|
||||
|
||||
List<CourseResponse> response = cquDataService.getSchedule(null, "2025-2026-1", "20230001");
|
||||
|
||||
assertThat(response).hasSize(1);
|
||||
assertThat(response.get(0).courseName()).isEqualTo("Java");
|
||||
assertThat(response.get(0).teacher()).isEqualTo("Zhang");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistGradesForLoggedInUserWhenAvailable() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(true);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setPasswordHash("encoded");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(cquApiClient.fetchGrades("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
|
||||
"courseName", "Java",
|
||||
"grade", 95,
|
||||
"semester", "2025-2026-1"
|
||||
)));
|
||||
Grade persisted = new Grade();
|
||||
persisted.setUser(user);
|
||||
persisted.setCourseName("Java");
|
||||
persisted.setGrade(95D);
|
||||
persisted.setSemester("2025-2026-1");
|
||||
persisted.setStudentId("20230001");
|
||||
when(gradeRepository.saveAll(anyList())).thenReturn(List.of(persisted));
|
||||
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
|
||||
.thenReturn(List.of(persisted));
|
||||
|
||||
List<GradeResponse> response = cquDataService.getGrades(user, "2025-2026-1", "20230001");
|
||||
|
||||
assertThat(response).hasSize(1);
|
||||
assertThat(response.get(0).grade()).isEqualTo(95D);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnPersistedScheduleWithoutCallingRemoteApiWhenRefreshIsDisabled() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(true);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
|
||||
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
|
||||
Course persisted = new Course();
|
||||
persisted.setUser(user);
|
||||
persisted.setCourseName("Java");
|
||||
persisted.setTeacher("Zhang");
|
||||
persisted.setClassroom("A101");
|
||||
persisted.setDayOfWeek(1);
|
||||
persisted.setStartTime(1);
|
||||
persisted.setEndTime(2);
|
||||
persisted.setSemester("2025-spring");
|
||||
persisted.setStudentId("20230001");
|
||||
|
||||
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
|
||||
.thenReturn(List.of(persisted));
|
||||
|
||||
List<CourseResponse> response = cquDataService.getSchedule(user, "2025-spring", "20230001", false);
|
||||
|
||||
assertThat(response).extracting(CourseResponse::courseName).containsExactly("Java");
|
||||
verifyNoInteractions(cquApiClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnLatestStoredSchoolDataFromPersistedUserContext() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(true);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
|
||||
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
user.setLastSchoolStudentId("20230001");
|
||||
user.setLastSchoolSemester("2025-spring");
|
||||
|
||||
Course course = new Course();
|
||||
course.setUser(user);
|
||||
course.setCourseName("Java");
|
||||
course.setTeacher("Zhang");
|
||||
course.setClassroom("A101");
|
||||
course.setDayOfWeek(1);
|
||||
course.setStartTime(1);
|
||||
course.setEndTime(2);
|
||||
course.setSemester("2025-spring");
|
||||
course.setStudentId("20230001");
|
||||
|
||||
Grade grade = new Grade();
|
||||
grade.setUser(user);
|
||||
grade.setCourseName("Java");
|
||||
grade.setGrade(95D);
|
||||
grade.setSemester("2025-spring");
|
||||
grade.setStudentId("20230001");
|
||||
|
||||
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
|
||||
.thenReturn(List.of(course));
|
||||
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
|
||||
.thenReturn(List.of(grade));
|
||||
|
||||
LatestSchoolDataResponse response = cquDataService.getLatest(user);
|
||||
|
||||
assertThat(response.studentId()).isEqualTo("20230001");
|
||||
assertThat(response.semester()).isEqualTo("2025-spring");
|
||||
assertThat(response.schedule()).extracting(CourseResponse::courseName).containsExactly("Java");
|
||||
assertThat(response.grades()).extracting(GradeResponse::courseName).containsExactly("Java");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToMostRecentStoredSchoolDataWhenUserContextIsEmpty() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(true);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
|
||||
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
|
||||
Course latestCourse = new Course();
|
||||
latestCourse.setUser(user);
|
||||
latestCourse.setCourseName("Java");
|
||||
latestCourse.setTeacher("Zhang");
|
||||
latestCourse.setClassroom("A101");
|
||||
latestCourse.setDayOfWeek(1);
|
||||
latestCourse.setStartTime(1);
|
||||
latestCourse.setEndTime(2);
|
||||
latestCourse.setSemester("2025-spring");
|
||||
latestCourse.setStudentId("20230001");
|
||||
latestCourse.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
when(courseRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(latestCourse));
|
||||
when(gradeRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.empty());
|
||||
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
|
||||
.thenReturn(List.of(latestCourse));
|
||||
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
|
||||
.thenReturn(List.of());
|
||||
|
||||
LatestSchoolDataResponse response = cquDataService.getLatest(user);
|
||||
|
||||
assertThat(response.studentId()).isEqualTo("20230001");
|
||||
assertThat(response.semester()).isEqualTo("2025-spring");
|
||||
assertThat(user.getLastSchoolStudentId()).isEqualTo("20230001");
|
||||
assertThat(user.getLastSchoolSemester()).isEqualTo("2025-spring");
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:cqu_tx_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.cqu.require-login=true",
|
||||
"app.cqu.mock-enabled=false"
|
||||
}
|
||||
)
|
||||
class CquDataServiceTransactionTest {
|
||||
|
||||
@Autowired
|
||||
private CquDataService cquDataService;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private GradeRepository gradeRepository;
|
||||
|
||||
@MockBean
|
||||
private CquApiClient cquApiClient;
|
||||
|
||||
@Test
|
||||
void shouldPersistGradesInsideTransactionForLoggedInUser() {
|
||||
User user = new User();
|
||||
user.setUsername("portal-demo");
|
||||
user.setEmail("portal-demo@example.com");
|
||||
user.setPasswordHash("encoded");
|
||||
user = userRepository.save(user);
|
||||
|
||||
Grade existing = new Grade();
|
||||
existing.setUser(user);
|
||||
existing.setCourseName("Old Java");
|
||||
existing.setGrade(60D);
|
||||
existing.setSemester("2025-spring");
|
||||
existing.setStudentId("2023123456");
|
||||
gradeRepository.save(existing);
|
||||
|
||||
when(cquApiClient.fetchGrades("2025-spring", "2023123456")).thenReturn(List.of(
|
||||
Map.of(
|
||||
"courseName", "Java",
|
||||
"grade", 95,
|
||||
"semester", "2025-spring"
|
||||
)
|
||||
));
|
||||
|
||||
List<GradeResponse> response = cquDataService.getGrades(user, "2025-spring", "2023123456", true);
|
||||
|
||||
assertThat(response).hasSize(1);
|
||||
assertThat(response.get(0).courseName()).isEqualTo("Java");
|
||||
assertThat(response.get(0).grade()).isEqualTo(95D);
|
||||
assertThat(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), "2023123456"))
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting(Grade::getCourseName)
|
||||
.isEqualTo("Java");
|
||||
assertThat(userRepository.findById(user.getId()))
|
||||
.get()
|
||||
.extracting(User::getLastSchoolStudentId, User::getLastSchoolSemester)
|
||||
.containsExactly("2023123456", "2025-spring");
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CquMockDataFactoryTest {
|
||||
|
||||
@Test
|
||||
void shouldCreateMockScheduleForStudentAndSemester() {
|
||||
List<Map<String, Object>> result = CquMockDataFactory.createSchedule("2025-2026-1", "20230001");
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
assertThat(result.get(0)).containsEntry("courseName", "高级 Java 程序设计");
|
||||
assertThat(result.get(0)).containsEntry("semester", "2025-2026-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateMockGradesForStudentAndSemester() {
|
||||
List<Map<String, Object>> result = CquMockDataFactory.createGrades("2025-2026-1", "20230001");
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
assertThat(result.get(0)).containsEntry("studentId", "20230001");
|
||||
assertThat(result.get(0)).containsKey("grade");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnDifferentMockDataForDifferentStudents() {
|
||||
List<Map<String, Object>> firstSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2023123456");
|
||||
List<Map<String, Object>> secondSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2022456789");
|
||||
List<Map<String, Object>> firstGrades = CquMockDataFactory.createGrades("2025-2026-1", "2023123456");
|
||||
List<Map<String, Object>> secondGrades = CquMockDataFactory.createGrades("2025-2026-1", "2022456789");
|
||||
|
||||
assertThat(firstSchedule).extracting(item -> item.get("courseName"))
|
||||
.isNotEqualTo(secondSchedule.stream().map(item -> item.get("courseName")).toList());
|
||||
assertThat(firstGrades).extracting(item -> item.get("grade"))
|
||||
.isNotEqualTo(secondGrades.stream().map(item -> item.get("grade")).toList());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.AdditionalMatchers.aryEq;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -43,13 +44,16 @@ class FileServiceTest {
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
@Mock
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
|
||||
private FileService fileService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, properties);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -167,6 +171,140 @@ class FileServiceTest {
|
||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMoveFileToAnotherDirectory() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
FileMetadataResponse response = fileService.move(user, 10L, "/下载");
|
||||
|
||||
assertThat(response.path()).isEqualTo("/下载");
|
||||
assertThat(file.getPath()).isEqualTo("/下载");
|
||||
verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMoveDirectoryAndUpdateDescendantPaths() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
||||
StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(storedFileRepository.saveAll(List.of(childFile))).thenReturn(List.of(childFile));
|
||||
|
||||
FileMetadataResponse response = fileService.move(user, 10L, "/图片");
|
||||
|
||||
assertThat(response.path()).isEqualTo("/图片/archive");
|
||||
assertThat(directory.getPath()).isEqualTo("/图片");
|
||||
assertThat(childFile.getPath()).isEqualTo("/图片/archive");
|
||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMovingDirectoryIntoItsOwnDescendant() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
||||
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
||||
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||
.thenReturn(Optional.of(docsDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||
.thenReturn(Optional.of(archiveDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested"))
|
||||
.thenReturn(Optional.of(descendantDirectory));
|
||||
|
||||
assertThatThrownBy(() -> fileService.move(user, 10L, "/docs/archive/nested"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("不能移动到当前目录或其子目录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCopyFileToAnotherDirectory() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile storedFile = invocation.getArgument(0);
|
||||
if (storedFile.getId() == null) {
|
||||
storedFile.setId(20L);
|
||||
}
|
||||
return storedFile;
|
||||
});
|
||||
|
||||
FileMetadataResponse response = fileService.copy(user, 10L, "/下载");
|
||||
|
||||
assertThat(response.id()).isEqualTo(20L);
|
||||
assertThat(response.path()).isEqualTo("/下载");
|
||||
verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCopyDirectoryAndDescendants() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
||||
StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested");
|
||||
StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt");
|
||||
StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
||||
.thenReturn(List.of(childDirectory, childFile, nestedFile));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "nested")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive/nested", "todo.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile storedFile = invocation.getArgument(0);
|
||||
if (storedFile.getId() == null) {
|
||||
storedFile.setId(100L + storedFile.getFilename().length());
|
||||
}
|
||||
return storedFile;
|
||||
});
|
||||
|
||||
FileMetadataResponse response = fileService.copy(user, 10L, "/图片");
|
||||
|
||||
assertThat(response.path()).isEqualTo("/图片/archive");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/图片/archive");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/图片/archive/nested");
|
||||
verify(fileContentStorage).copyFile(7L, "/docs/archive", "/图片/archive", "notes.txt");
|
||||
verify(fileContentStorage).copyFile(7L, "/docs/archive/nested", "/图片/archive/nested", "todo.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCopyingDirectoryIntoItsOwnDescendant() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
||||
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
||||
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||
.thenReturn(Optional.of(docsDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||
.thenReturn(Optional.of(archiveDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested"))
|
||||
.thenReturn(Optional.of(descendantDirectory));
|
||||
|
||||
assertThatThrownBy(() -> fileService.copy(user, 10L, "/docs/archive/nested"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("不能复制到当前目录或其子目录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDeletingOtherUsersFile() {
|
||||
User owner = createUser(1L);
|
||||
@@ -293,6 +431,60 @@ class FileServiceTest {
|
||||
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateShareLinkForOwnedFile() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> {
|
||||
FileShareLink shareLink = invocation.getArgument(0);
|
||||
shareLink.setId(100L);
|
||||
shareLink.setToken("share-token-1");
|
||||
return shareLink;
|
||||
});
|
||||
|
||||
CreateFileShareLinkResponse response = fileService.createShareLink(user, 22L);
|
||||
|
||||
assertThat(response.token()).isEqualTo("share-token-1");
|
||||
assertThat(response.filename()).isEqualTo("notes.txt");
|
||||
verify(fileShareLinkRepository).save(any(FileShareLink.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldImportSharedFileIntoRecipientWorkspace() {
|
||||
User owner = createUser(7L);
|
||||
User recipient = createUser(8L);
|
||||
StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt");
|
||||
FileShareLink shareLink = new FileShareLink();
|
||||
shareLink.setId(100L);
|
||||
shareLink.setToken("share-token-1");
|
||||
shareLink.setOwner(owner);
|
||||
shareLink.setFile(sourceFile);
|
||||
shareLink.setCreatedAt(LocalDateTime.now());
|
||||
when(fileShareLinkRepository.findByToken("share-token-1")).thenReturn(Optional.of(shareLink));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile file = invocation.getArgument(0);
|
||||
file.setId(200L);
|
||||
return file;
|
||||
});
|
||||
when(fileContentStorage.readFile(7L, "/docs", "notes.txt"))
|
||||
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载");
|
||||
|
||||
assertThat(response.id()).isEqualTo(200L);
|
||||
assertThat(response.path()).isEqualTo("/下载");
|
||||
assertThat(response.filename()).isEqualTo("notes.txt");
|
||||
verify(fileContentStorage).storeImportedFile(
|
||||
eq(8L),
|
||||
eq("/下载"),
|
||||
eq("notes.txt"),
|
||||
eq(sourceFile.getContentType()),
|
||||
aryEq("hello".getBytes(StandardCharsets.UTF_8))
|
||||
);
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:file_share_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-file-share"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class FileShareControllerIntegrationTest {
|
||||
|
||||
private static final Path STORAGE_ROOT = Path.of("./target/test-storage-file-share").toAbsolutePath().normalize();
|
||||
private Long sharedFileId;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@Autowired
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
fileShareLinkRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
if (Files.exists(STORAGE_ROOT)) {
|
||||
try (var paths = Files.walk(STORAGE_ROOT)) {
|
||||
paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Files.createDirectories(STORAGE_ROOT);
|
||||
|
||||
User owner = new User();
|
||||
owner.setUsername("alice");
|
||||
owner.setEmail("alice@example.com");
|
||||
owner.setPhoneNumber("13800138000");
|
||||
owner.setPasswordHash("encoded-password");
|
||||
owner.setCreatedAt(LocalDateTime.now());
|
||||
owner = userRepository.save(owner);
|
||||
|
||||
User recipient = new User();
|
||||
recipient.setUsername("bob");
|
||||
recipient.setEmail("bob@example.com");
|
||||
recipient.setPhoneNumber("13800138001");
|
||||
recipient.setPasswordHash("encoded-password");
|
||||
recipient.setCreatedAt(LocalDateTime.now());
|
||||
recipient = userRepository.save(recipient);
|
||||
|
||||
StoredFile file = new StoredFile();
|
||||
file.setUser(owner);
|
||||
file.setFilename("notes.txt");
|
||||
file.setPath("/docs");
|
||||
file.setStorageName("notes.txt");
|
||||
file.setContentType("text/plain");
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
sharedFileId = storedFileRepository.save(file).getId();
|
||||
|
||||
Path ownerDir = STORAGE_ROOT.resolve(owner.getId().toString()).resolve("docs");
|
||||
Files.createDirectories(ownerDir);
|
||||
Files.writeString(ownerDir.resolve("notes.txt"), "hello", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateInspectAndImportSharedFile() throws Exception {
|
||||
String response = mockMvc.perform(post("/api/files/{fileId}/share-links", sharedFileId)
|
||||
.with(user("alice")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.token").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String token = com.jayway.jsonpath.JsonPath.read(response, "$.data.token");
|
||||
|
||||
mockMvc.perform(get("/api/files/share-links/{token}", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.token").value(token))
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andExpect(jsonPath("$.data.ownerUsername").value("alice"));
|
||||
|
||||
mockMvc.perform(post("/api/files/share-links/{token}/import", token)
|
||||
.with(anonymous())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/下载"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
mockMvc.perform(post("/api/files/share-links/{token}/import", token)
|
||||
.with(user("bob"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/下载"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andExpect(jsonPath("$.data.path").value("/下载"));
|
||||
|
||||
mockMvc.perform(get("/api/files/list")
|
||||
.with(user("bob"))
|
||||
.param("path", "/下载")
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMoveFileIntoAnotherDirectoryThroughApi() throws Exception {
|
||||
User owner = userRepository.findByUsername("alice").orElseThrow();
|
||||
|
||||
StoredFile downloadDirectory = new StoredFile();
|
||||
downloadDirectory.setUser(owner);
|
||||
downloadDirectory.setFilename("下载");
|
||||
downloadDirectory.setPath("/");
|
||||
downloadDirectory.setStorageName("下载");
|
||||
downloadDirectory.setContentType("directory");
|
||||
downloadDirectory.setSize(0L);
|
||||
downloadDirectory.setDirectory(true);
|
||||
storedFileRepository.save(downloadDirectory);
|
||||
Files.createDirectories(STORAGE_ROOT.resolve(owner.getId().toString()).resolve("下载"));
|
||||
|
||||
mockMvc.perform(patch("/api/files/{fileId}/move", sharedFileId)
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/下载"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andExpect(jsonPath("$.data.path").value("/下载"));
|
||||
|
||||
mockMvc.perform(get("/api/files/list")
|
||||
.with(user("alice"))
|
||||
.param("path", "/下载")
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCopyFileIntoAnotherDirectoryThroughApi() throws Exception {
|
||||
User owner = userRepository.findByUsername("alice").orElseThrow();
|
||||
|
||||
StoredFile downloadDirectory = new StoredFile();
|
||||
downloadDirectory.setUser(owner);
|
||||
downloadDirectory.setFilename("下载");
|
||||
downloadDirectory.setPath("/");
|
||||
downloadDirectory.setStorageName("下载");
|
||||
downloadDirectory.setContentType("directory");
|
||||
downloadDirectory.setSize(0L);
|
||||
downloadDirectory.setDirectory(true);
|
||||
storedFileRepository.save(downloadDirectory);
|
||||
Files.createDirectories(STORAGE_ROOT.resolve(owner.getId().toString()).resolve("下载"));
|
||||
|
||||
mockMvc.perform(post("/api/files/{fileId}/copy", sharedFileId)
|
||||
.with(user("alice"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/下载"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
|
||||
.andExpect(jsonPath("$.data.path").value("/下载"));
|
||||
|
||||
mockMvc.perform(get("/api/files/list")
|
||||
.with(user("alice"))
|
||||
.param("path", "/下载")
|
||||
.param("page", "0")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:transfer_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-transfer"
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class TransferControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository.deleteAll();
|
||||
|
||||
User portalUser = new User();
|
||||
portalUser.setUsername("alice");
|
||||
portalUser.setEmail("alice@example.com");
|
||||
portalUser.setPhoneNumber("13800138000");
|
||||
portalUser.setPasswordHash("encoded-password");
|
||||
portalUser.setCreatedAt(LocalDateTime.now());
|
||||
userRepository.save(portalUser);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice")
|
||||
void shouldCreateLookupJoinAndPollTransferSignals() throws Exception {
|
||||
String response = mockMvc.perform(post("/api/transfer/sessions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"files": [
|
||||
{"name": "report.pdf", "size": 2048, "contentType": "application/pdf"}
|
||||
]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.sessionId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.pickupCode").isString())
|
||||
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String sessionId = com.jayway.jsonpath.JsonPath.read(response, "$.data.sessionId");
|
||||
String pickupCode = com.jayway.jsonpath.JsonPath.read(response, "$.data.pickupCode");
|
||||
|
||||
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
||||
.andExpect(jsonPath("$.data.pickupCode").value(pickupCode));
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
||||
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"));
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/signals", sessionId)
|
||||
.param("role", "sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"type": "offer",
|
||||
"payload": "{\\\"sdp\\\":\\\"demo-offer\\\"}"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/signals", sessionId)
|
||||
.param("role", "receiver")
|
||||
.param("after", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].type").value("offer"))
|
||||
.andExpect(jsonPath("$.data.items[0].payload").value("{\"sdp\":\"demo-offer\"}"))
|
||||
.andExpect(jsonPath("$.data.nextCursor").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectAnonymousSessionCreationButAllowPublicJoinEndpoints() throws Exception {
|
||||
mockMvc.perform(post("/api/transfer/sessions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"files":[{"name":"demo.txt","size":12,"contentType":"text/plain"}]}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TransferSessionTest {
|
||||
|
||||
@Test
|
||||
void shouldEmitPeerJoinedOnlyOnceWhenReceiverJoinsRepeatedly() {
|
||||
TransferSession session = new TransferSession(
|
||||
"session-1",
|
||||
"849201",
|
||||
Instant.parse("2026-03-20T12:00:00Z"),
|
||||
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
|
||||
);
|
||||
|
||||
session.markReceiverJoined();
|
||||
session.markReceiverJoined();
|
||||
|
||||
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
|
||||
|
||||
assertThat(senderSignals.items())
|
||||
.extracting(TransferSignalEnvelope::type)
|
||||
.containsExactly("peer-joined");
|
||||
assertThat(senderSignals.nextCursor()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRouteSignalsToTheOppositeRoleQueue() {
|
||||
TransferSession session = new TransferSession(
|
||||
"session-1",
|
||||
"849201",
|
||||
Instant.parse("2026-03-20T12:00:00Z"),
|
||||
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
|
||||
);
|
||||
|
||||
session.enqueue(TransferRole.SENDER, "offer", "{\"sdp\":\"demo-offer\"}");
|
||||
session.enqueue(TransferRole.RECEIVER, "answer", "{\"sdp\":\"demo-answer\"}");
|
||||
|
||||
PollTransferSignalsResponse receiverSignals = session.poll(TransferRole.RECEIVER, 0);
|
||||
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
|
||||
|
||||
assertThat(receiverSignals.items())
|
||||
.extracting(TransferSignalEnvelope::type, TransferSignalEnvelope::payload)
|
||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("offer", "{\"sdp\":\"demo-offer\"}"));
|
||||
assertThat(senderSignals.items())
|
||||
.extracting(TransferSignalEnvelope::type, TransferSignalEnvelope::payload)
|
||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("answer", "{\"sdp\":\"demo-answer\"}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user