实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能

This commit is contained in:
yoyuzh
2026-03-20 14:16:18 +08:00
parent 944ab6dbf8
commit 43358e29d7
109 changed files with 5237 additions and 2465 deletions

View File

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

View File

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

View File

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

View File

@@ -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, "用户不存在"));

View File

@@ -2,7 +2,6 @@ package com.yoyuzh.admin;
public record AdminSummaryResponse(
long totalUsers,
long totalFiles,
long usersWithSchoolCache
long totalFiles
) {
}

View File

@@ -10,8 +10,6 @@ public record AdminUserResponse(
String email,
String phoneNumber,
LocalDateTime createdAt,
String lastSchoolStudentId,
String lastSchoolSemester,
UserRole role,
boolean banned
) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
package com.yoyuzh.cqu;
public record CourseResponse(
String courseName,
String teacher,
String classroom,
Integer dayOfWeek,
Integer startTime,
Integer endTime
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
package com.yoyuzh.cqu;
public record GradeResponse(
String courseName,
Double grade,
String semester
) {
}

View File

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

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record CopyFileRequest(
@NotBlank(message = "目标路径不能为空")
String path
) {
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record ImportSharedFileRequest(@NotBlank String path) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record MoveFileRequest(
@NotBlank(message = "目标路径不能为空")
String path
) {
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
package com.yoyuzh.transfer;
import java.time.Instant;
public record LookupTransferSessionResponse(
String sessionId,
String pickupCode,
Instant expiresAt
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.transfer;
import java.util.List;
public record PollTransferSignalsResponse(
List<TransferSignalEnvelope> items,
long nextCursor
) {
}

View File

@@ -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, "用户未登录");
}
}
}

View File

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

View 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, "不支持的传输角色");
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.transfer;
public record TransferSignalEnvelope(
long cursor,
String type,
String payload
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.transfer;
import jakarta.validation.constraints.NotBlank;
public record TransferSignalRequest(
@NotBlank(message = "信令类型不能为空")
String type,
@NotBlank(message = "信令内容不能为空")
String payload
) {
}

View File

@@ -17,5 +17,3 @@ app:
secret: ${APP_JWT_SECRET:}
admin:
usernames: ${APP_ADMIN_USERNAMES:}
cqu:
mock-enabled: true

View File

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

View File

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

View File

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

View File

@@ -28,4 +28,5 @@ class SecurityConfigTest {
assertThat(configuration).isNotNull();
assertThat(configuration.getAllowedMethods()).contains("PATCH");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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