实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
package com.yoyuzh;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import com.yoyuzh.config.CorsProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -13,7 +12,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
@EnableConfigurationProperties({
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CquApiProperties.class,
|
||||
CorsProperties.class,
|
||||
AdminProperties.class
|
||||
})
|
||||
|
||||
@@ -50,13 +50,6 @@ public class AdminController {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@GetMapping("/school-snapshots")
|
||||
public ApiResponse<PageResponse<AdminSchoolSnapshotResponse>> schoolSnapshots(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ApiResponse.success(adminService.listSchoolSnapshots(page, size));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/role")
|
||||
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSchoolSnapshotResponse(
|
||||
Long id,
|
||||
Long userId,
|
||||
String username,
|
||||
String email,
|
||||
String studentId,
|
||||
String semester,
|
||||
long scheduleCount,
|
||||
long gradeCount
|
||||
) {
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import com.yoyuzh.auth.RefreshTokenService;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.cqu.CourseRepository;
|
||||
import com.yoyuzh.cqu.GradeRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
@@ -31,8 +29,6 @@ public class AdminService {
|
||||
private final UserRepository userRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileService fileService;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
@@ -40,8 +36,7 @@ public class AdminService {
|
||||
public AdminSummaryResponse getSummary() {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
userRepository.countByLastSchoolStudentIdIsNotNull()
|
||||
storedFileRepository.count()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,16 +64,6 @@ public class AdminService {
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<AdminSchoolSnapshotResponse> listSchoolSnapshots(int page, int size) {
|
||||
Page<User> result = userRepository.findByLastSchoolStudentIdIsNotNull(
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
List<AdminSchoolSnapshotResponse> items = result.getContent().stream()
|
||||
.map(this::toSchoolSnapshotResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteFile(Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
@@ -126,8 +111,6 @@ public class AdminService {
|
||||
user.getEmail(),
|
||||
user.getPhoneNumber(),
|
||||
user.getCreatedAt(),
|
||||
user.getLastSchoolStudentId(),
|
||||
user.getLastSchoolSemester(),
|
||||
user.getRole(),
|
||||
user.isBanned()
|
||||
);
|
||||
@@ -149,28 +132,6 @@ public class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
private AdminSchoolSnapshotResponse toSchoolSnapshotResponse(User user) {
|
||||
String studentId = user.getLastSchoolStudentId();
|
||||
String semester = user.getLastSchoolSemester();
|
||||
long scheduleCount = studentId == null || semester == null
|
||||
? 0
|
||||
: courseRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
long gradeCount = studentId == null || semester == null
|
||||
? 0
|
||||
: gradeRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
|
||||
return new AdminSchoolSnapshotResponse(
|
||||
user.getId(),
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
studentId,
|
||||
semester,
|
||||
scheduleCount,
|
||||
gradeCount
|
||||
);
|
||||
}
|
||||
|
||||
private User getRequiredUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles,
|
||||
long usersWithSchoolCache
|
||||
long totalFiles
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ public record AdminUserResponse(
|
||||
String email,
|
||||
String phoneNumber,
|
||||
LocalDateTime createdAt,
|
||||
String lastSchoolStudentId,
|
||||
String lastSchoolSemester,
|
||||
UserRole role,
|
||||
boolean banned
|
||||
) {
|
||||
|
||||
@@ -40,12 +40,6 @@ public class User {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "last_school_student_id", length = 64)
|
||||
private String lastSchoolStudentId;
|
||||
|
||||
@Column(name = "last_school_semester", length = 64)
|
||||
private String lastSchoolSemester;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 64)
|
||||
private String displayName;
|
||||
|
||||
@@ -135,22 +129,6 @@ public class User {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getLastSchoolStudentId() {
|
||||
return lastSchoolStudentId;
|
||||
}
|
||||
|
||||
public void setLastSchoolStudentId(String lastSchoolStudentId) {
|
||||
this.lastSchoolStudentId = lastSchoolStudentId;
|
||||
}
|
||||
|
||||
public String getLastSchoolSemester() {
|
||||
return lastSchoolSemester;
|
||||
}
|
||||
|
||||
public void setLastSchoolSemester(String lastSchoolSemester) {
|
||||
this.lastSchoolSemester = lastSchoolSemester;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
long countByLastSchoolStudentIdIsNotNull();
|
||||
|
||||
Page<User> findByLastSchoolStudentIdIsNotNull(Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select u from User u
|
||||
where (:query is null or :query = ''
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.cqu")
|
||||
public class CquApiProperties {
|
||||
|
||||
private String baseUrl = "https://example-cqu-api.local";
|
||||
private boolean requireLogin = false;
|
||||
private boolean mockEnabled = false;
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public boolean isRequireLogin() {
|
||||
return requireLogin;
|
||||
}
|
||||
|
||||
public void setRequireLogin(boolean requireLogin) {
|
||||
this.requireLogin = requireLogin;
|
||||
}
|
||||
|
||||
public boolean isMockEnabled() {
|
||||
return mockEnabled;
|
||||
}
|
||||
|
||||
public void setMockEnabled(boolean mockEnabled) {
|
||||
this.mockEnabled = mockEnabled;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
@@ -47,9 +48,13 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/transfer/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/admin/**")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
|
||||
.requestMatchers("/api/files/**", "/api/user/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_course", indexes = {
|
||||
@Index(name = "idx_course_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_course_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Course {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(length = 255)
|
||||
private String teacher;
|
||||
|
||||
@Column(length = 255)
|
||||
private String classroom;
|
||||
|
||||
@Column(name = "day_of_week")
|
||||
private Integer dayOfWeek;
|
||||
|
||||
@Column(name = "start_time")
|
||||
private Integer startTime;
|
||||
|
||||
@Column(name = "end_time")
|
||||
private Integer endTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public String getTeacher() {
|
||||
return teacher;
|
||||
}
|
||||
|
||||
public void setTeacher(String teacher) {
|
||||
this.teacher = teacher;
|
||||
}
|
||||
|
||||
public String getClassroom() {
|
||||
return classroom;
|
||||
}
|
||||
|
||||
public void setClassroom(String classroom) {
|
||||
this.classroom = classroom;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public Integer getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Integer startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Integer getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Integer endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CourseRepository extends JpaRepository<Course, Long> {
|
||||
List<Course> findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester);
|
||||
|
||||
Optional<Course> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record CourseResponse(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CquApiClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final CquApiProperties properties;
|
||||
|
||||
public List<Map<String, Object>> fetchSchedule(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createSchedule(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/schedule?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> fetchGrades(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createGrades(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/grades?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cqu")
|
||||
@RequiredArgsConstructor
|
||||
public class CquController {
|
||||
|
||||
private final CquDataService cquDataService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "获取课表")
|
||||
@GetMapping("/schedule")
|
||||
public ApiResponse<List<CourseResponse>> schedule(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId,
|
||||
@RequestParam(defaultValue = "false") boolean refresh) {
|
||||
return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId, refresh));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取成绩")
|
||||
@GetMapping("/grades")
|
||||
public ApiResponse<List<GradeResponse>> grades(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId,
|
||||
@RequestParam(defaultValue = "false") boolean refresh) {
|
||||
return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId, refresh));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取最近一次教务数据")
|
||||
@GetMapping("/latest")
|
||||
public ApiResponse<LatestSchoolDataResponse> latest(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return ApiResponse.success(cquDataService.getLatest(resolveUser(userDetails)));
|
||||
}
|
||||
|
||||
private User resolveUser(UserDetails userDetails) {
|
||||
return userDetails == null ? null : userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CquDataService {
|
||||
|
||||
private final CquApiClient cquApiClient;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CquApiProperties cquApiProperties;
|
||||
|
||||
@Transactional
|
||||
public List<CourseResponse> getSchedule(User user, String semester, String studentId) {
|
||||
return getSchedule(user, semester, studentId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<CourseResponse> getSchedule(User user, String semester, String studentId, boolean refresh) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user != null && !refresh) {
|
||||
List<CourseResponse> stored = readSavedSchedule(user.getId(), studentId, semester);
|
||||
if (!stored.isEmpty()) {
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream()
|
||||
.map(this::toCourseResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveCourses(user, semester, studentId, responses);
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedSchedule(user.getId(), studentId, semester);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<GradeResponse> getGrades(User user, String semester, String studentId) {
|
||||
return getGrades(user, semester, studentId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<GradeResponse> getGrades(User user, String semester, String studentId, boolean refresh) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user != null && !refresh
|
||||
&& gradeRepository.existsByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester)) {
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedGrades(user.getId(), studentId);
|
||||
}
|
||||
|
||||
List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream()
|
||||
.map(this::toGradeResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveGrades(user, semester, studentId, responses);
|
||||
rememberLastSchoolQuery(user, studentId, semester);
|
||||
return readSavedGrades(user.getId(), studentId);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LatestSchoolDataResponse getLatest(User user) {
|
||||
requireLoginIfNecessary(user);
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
QueryContext context = resolveLatestContext(user);
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<CourseResponse> schedule = readSavedSchedule(user.getId(), context.studentId(), context.semester());
|
||||
List<GradeResponse> grades = readSavedGrades(user.getId(), context.studentId());
|
||||
if (schedule.isEmpty() && grades.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LatestSchoolDataResponse(context.studentId(), context.semester(), schedule, grades);
|
||||
}
|
||||
|
||||
private void requireLoginIfNecessary(User user) {
|
||||
if (cquApiProperties.isRequireLogin() && user == null) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "该接口需要登录后访问");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveCourses(User user, String semester, String studentId, List<CourseResponse> responses) {
|
||||
courseRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
courseRepository.saveAll(responses.stream().map(item -> {
|
||||
Course course = new Course();
|
||||
course.setUser(user);
|
||||
course.setCourseName(item.courseName());
|
||||
course.setSemester(semester);
|
||||
course.setStudentId(studentId);
|
||||
course.setTeacher(item.teacher());
|
||||
course.setClassroom(item.classroom());
|
||||
course.setDayOfWeek(item.dayOfWeek());
|
||||
course.setStartTime(item.startTime());
|
||||
course.setEndTime(item.endTime());
|
||||
return course;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveGrades(User user, String semester, String studentId, List<GradeResponse> responses) {
|
||||
gradeRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
gradeRepository.saveAll(responses.stream().map(item -> {
|
||||
Grade grade = new Grade();
|
||||
grade.setUser(user);
|
||||
grade.setCourseName(item.courseName());
|
||||
grade.setGrade(item.grade());
|
||||
grade.setSemester(item.semester() == null ? semester : item.semester());
|
||||
grade.setStudentId(studentId);
|
||||
return grade;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
private List<CourseResponse> readSavedSchedule(Long userId, String studentId, String semester) {
|
||||
return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(
|
||||
userId, studentId, semester)
|
||||
.stream()
|
||||
.map(item -> new CourseResponse(
|
||||
item.getCourseName(),
|
||||
item.getTeacher(),
|
||||
item.getClassroom(),
|
||||
item.getDayOfWeek(),
|
||||
item.getStartTime(),
|
||||
item.getEndTime()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<GradeResponse> readSavedGrades(Long userId, String studentId) {
|
||||
return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(userId, studentId)
|
||||
.stream()
|
||||
.map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void rememberLastSchoolQuery(User user, String studentId, String semester) {
|
||||
boolean changed = false;
|
||||
if (!semester.equals(user.getLastSchoolSemester())) {
|
||||
user.setLastSchoolSemester(semester);
|
||||
changed = true;
|
||||
}
|
||||
if (!studentId.equals(user.getLastSchoolStudentId())) {
|
||||
user.setLastSchoolStudentId(studentId);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
private QueryContext resolveLatestContext(User user) {
|
||||
if (hasText(user.getLastSchoolStudentId()) && hasText(user.getLastSchoolSemester())) {
|
||||
return new QueryContext(user.getLastSchoolStudentId(), user.getLastSchoolSemester());
|
||||
}
|
||||
|
||||
Optional<Course> latestCourse = courseRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
Optional<Grade> latestGrade = gradeRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
if (latestCourse.isEmpty() && latestGrade.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
QueryContext context;
|
||||
if (latestGrade.isEmpty()) {
|
||||
context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester());
|
||||
} else if (latestCourse.isEmpty()) {
|
||||
context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester());
|
||||
} else if (latestCourse.get().getCreatedAt().isAfter(latestGrade.get().getCreatedAt())) {
|
||||
context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester());
|
||||
} else {
|
||||
context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester());
|
||||
}
|
||||
|
||||
if (hasText(context.studentId()) && hasText(context.semester())) {
|
||||
user.setLastSchoolStudentId(context.studentId());
|
||||
user.setLastSchoolSemester(context.semester());
|
||||
userRepository.save(user);
|
||||
return context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private CourseResponse toCourseResponse(Map<String, Object> source) {
|
||||
return new CourseResponse(
|
||||
stringValue(source, "courseName"),
|
||||
stringValue(source, "teacher"),
|
||||
stringValue(source, "classroom"),
|
||||
intValue(source, "dayOfWeek"),
|
||||
intValue(source, "startTime"),
|
||||
intValue(source, "endTime"));
|
||||
}
|
||||
|
||||
private GradeResponse toGradeResponse(Map<String, Object> source) {
|
||||
return new GradeResponse(
|
||||
stringValue(source, "courseName"),
|
||||
doubleValue(source, "grade"),
|
||||
stringValue(source, "semester"));
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Integer.parseInt(value.toString());
|
||||
}
|
||||
|
||||
private Double doubleValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Double.parseDouble(value.toString());
|
||||
}
|
||||
|
||||
private record QueryContext(String studentId, String semester) {
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CquMockDataFactory {
|
||||
|
||||
private CquMockDataFactory() {
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createSchedule(String semester, String studentId) {
|
||||
StudentProfile profile = StudentProfile.fromStudentId(studentId);
|
||||
return profile.schedule().stream()
|
||||
.map(item -> Map.<String, Object>of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", item.courseName(),
|
||||
"teacher", item.teacher(),
|
||||
"classroom", item.classroom(),
|
||||
"dayOfWeek", item.dayOfWeek(),
|
||||
"startTime", item.startTime(),
|
||||
"endTime", item.endTime()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createGrades(String semester, String studentId) {
|
||||
StudentProfile profile = StudentProfile.fromStudentId(studentId);
|
||||
return profile.grades().stream()
|
||||
.map(item -> Map.<String, Object>of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", item.courseName(),
|
||||
"grade", item.score()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ScheduleEntry(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
|
||||
private record GradeEntry(String courseName, Double score) {
|
||||
}
|
||||
|
||||
private record StudentProfile(
|
||||
List<ScheduleEntry> schedule,
|
||||
List<GradeEntry> grades
|
||||
) {
|
||||
private static StudentProfile fromStudentId(String studentId) {
|
||||
return switch (studentId) {
|
||||
case "2023123456" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2),
|
||||
new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4),
|
||||
new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||
new GradeEntry("计算机网络", 88.5),
|
||||
new GradeEntry("软件工程", 90.0)
|
||||
)
|
||||
);
|
||||
case "2022456789" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("数据挖掘", "陈老师", "A1408", 2, 1, 2),
|
||||
new ScheduleEntry("机器学习基础", "赵老师", "B2201", 4, 3, 4),
|
||||
new ScheduleEntry("信息检索", "孙老师", "C1205", 5, 7, 8)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("数据挖掘", 94.0),
|
||||
new GradeEntry("机器学习基础", 91.0),
|
||||
new GradeEntry("信息检索", 89.0)
|
||||
)
|
||||
);
|
||||
case "2021789012" -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("交互设计", "刘老师", "艺设楼201", 1, 3, 4),
|
||||
new ScheduleEntry("视觉传达专题", "黄老师", "艺设楼305", 3, 5, 6),
|
||||
new ScheduleEntry("数字媒体项目实践", "许老师", "创意工坊101", 4, 7, 8)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("交互设计", 96.0),
|
||||
new GradeEntry("视觉传达专题", 93.0),
|
||||
new GradeEntry("数字媒体项目实践", 97.0)
|
||||
)
|
||||
);
|
||||
default -> new StudentProfile(
|
||||
List.of(
|
||||
new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2),
|
||||
new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4),
|
||||
new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6)
|
||||
),
|
||||
List.of(
|
||||
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||
new GradeEntry("计算机网络", 88.5),
|
||||
new GradeEntry("软件工程", 90.0)
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_grade", indexes = {
|
||||
@Index(name = "idx_grade_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_grade_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Grade {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Double grade;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Double getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(Double grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GradeRepository extends JpaRepository<Grade, Long> {
|
||||
List<Grade> findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId);
|
||||
|
||||
boolean existsByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
Optional<Grade> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record GradeResponse(
|
||||
String courseName,
|
||||
Double grade,
|
||||
String semester
|
||||
) {
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LatestSchoolDataResponse(
|
||||
String studentId,
|
||||
String semester,
|
||||
List<CourseResponse> schedule,
|
||||
List<GradeResponse> grades
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CopyFileRequest(
|
||||
@NotBlank(message = "目标路径不能为空")
|
||||
String path
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CreateFileShareLinkResponse(
|
||||
String token,
|
||||
String filename,
|
||||
long size,
|
||||
String contentType,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -108,6 +108,53 @@ public class FileController {
|
||||
fileService.rename(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.filename()));
|
||||
}
|
||||
|
||||
@Operation(summary = "移动文件")
|
||||
@PatchMapping("/{fileId}/move")
|
||||
public ApiResponse<FileMetadataResponse> move(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId,
|
||||
@Valid @RequestBody MoveFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.move(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.path()));
|
||||
}
|
||||
|
||||
@Operation(summary = "复制文件")
|
||||
@PostMapping("/{fileId}/copy")
|
||||
public ApiResponse<FileMetadataResponse> copy(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId,
|
||||
@Valid @RequestBody CopyFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.copy(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.path()));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建分享链接")
|
||||
@PostMapping("/{fileId}/share-links")
|
||||
public ApiResponse<CreateFileShareLinkResponse> createShareLink(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId) {
|
||||
return ApiResponse.success(
|
||||
fileService.createShareLink(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId)
|
||||
);
|
||||
}
|
||||
|
||||
@Operation(summary = "查看分享详情")
|
||||
@GetMapping("/share-links/{token}")
|
||||
public ApiResponse<FileShareDetailsResponse> getShareDetails(@PathVariable String token) {
|
||||
return ApiResponse.success(fileService.getShareDetails(token));
|
||||
}
|
||||
|
||||
@Operation(summary = "导入共享文件")
|
||||
@PostMapping("/share-links/{token}/import")
|
||||
public ApiResponse<FileMetadataResponse> importSharedFile(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String token,
|
||||
@Valid @RequestBody ImportSharedFileRequest request) {
|
||||
return ApiResponse.success(
|
||||
fileService.importSharedFile(
|
||||
userDetailsService.loadDomainUser(userDetails.getUsername()),
|
||||
token,
|
||||
request.path()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件")
|
||||
@DeleteMapping("/{fileId}")
|
||||
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,
|
||||
|
||||
@@ -22,10 +22,12 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -35,13 +37,16 @@ public class FileService {
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final FileShareLinkRepository fileShareLinkRepository;
|
||||
private final long maxFileSize;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileShareLinkRepository fileShareLinkRepository,
|
||||
FileStorageProperties properties) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
}
|
||||
|
||||
@@ -207,6 +212,105 @@ public class FileService {
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse move(User user, Long fileId, String nextPath) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "移动");
|
||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||
if (normalizedTargetPath.equals(storedFile.getPath())) {
|
||||
return toResponse(storedFile);
|
||||
}
|
||||
|
||||
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (storedFile.isDirectory()) {
|
||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||
String newLogicalPath = "/".equals(normalizedTargetPath)
|
||||
? "/" + storedFile.getFilename()
|
||||
: normalizedTargetPath + "/" + storedFile.getFilename();
|
||||
if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "不能移动到当前目录或其子目录");
|
||||
}
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
||||
for (StoredFile descendant : descendants) {
|
||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||
descendant.setPath(newLogicalPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
descendant.setPath(newLogicalPath + descendant.getPath().substring(oldLogicalPath.length()));
|
||||
}
|
||||
if (!descendants.isEmpty()) {
|
||||
storedFileRepository.saveAll(descendants);
|
||||
}
|
||||
} else {
|
||||
fileContentStorage.moveFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
}
|
||||
|
||||
storedFile.setPath(normalizedTargetPath);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse copy(User user, Long fileId, String nextPath) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "复制");
|
||||
String normalizedTargetPath = normalizeDirectoryPath(nextPath);
|
||||
ensureExistingDirectoryPath(user.getId(), normalizedTargetPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (!storedFile.isDirectory()) {
|
||||
fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath)));
|
||||
}
|
||||
|
||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||
String newLogicalPath = buildTargetLogicalPath(normalizedTargetPath, storedFile.getFilename());
|
||||
if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "不能复制到当前目录或其子目录");
|
||||
}
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
List<StoredFile> copiedEntries = new ArrayList<>();
|
||||
|
||||
fileContentStorage.ensureDirectory(user.getId(), newLogicalPath);
|
||||
StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath);
|
||||
copiedEntries.add(copiedRoot);
|
||||
|
||||
descendants.stream()
|
||||
.sorted(Comparator
|
||||
.comparingInt((StoredFile descendant) -> descendant.getPath().length())
|
||||
.thenComparing(descendant -> descendant.isDirectory() ? 0 : 1)
|
||||
.thenComparing(StoredFile::getFilename))
|
||||
.forEach(descendant -> {
|
||||
String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||
}
|
||||
|
||||
if (descendant.isDirectory()) {
|
||||
fileContentStorage.ensureDirectory(user.getId(), buildTargetLogicalPath(copiedPath, descendant.getFilename()));
|
||||
} else {
|
||||
fileContentStorage.copyFile(user.getId(), descendant.getPath(), copiedPath, descendant.getStorageName());
|
||||
}
|
||||
copiedEntries.add(copyStoredFile(descendant, copiedPath));
|
||||
});
|
||||
|
||||
StoredFile savedRoot = null;
|
||||
for (StoredFile copiedEntry : copiedEntries) {
|
||||
StoredFile savedEntry = storedFileRepository.save(copiedEntry);
|
||||
if (savedRoot == null) {
|
||||
savedRoot = savedEntry;
|
||||
}
|
||||
}
|
||||
return toResponse(savedRoot == null ? copiedRoot : savedRoot);
|
||||
}
|
||||
|
||||
public ResponseEntity<?> download(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "下载");
|
||||
if (storedFile.isDirectory()) {
|
||||
@@ -249,6 +353,78 @@ public class FileService {
|
||||
return new DownloadUrlResponse("/api/files/download/" + storedFile.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CreateFileShareLinkResponse createShareLink(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "分享");
|
||||
if (storedFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接");
|
||||
}
|
||||
|
||||
FileShareLink shareLink = new FileShareLink();
|
||||
shareLink.setOwner(user);
|
||||
shareLink.setFile(storedFile);
|
||||
shareLink.setToken(UUID.randomUUID().toString().replace("-", ""));
|
||||
FileShareLink saved = fileShareLinkRepository.save(shareLink);
|
||||
|
||||
return new CreateFileShareLinkResponse(
|
||||
saved.getToken(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
saved.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public FileShareDetailsResponse getShareDetails(String token) {
|
||||
FileShareLink shareLink = getShareLink(token);
|
||||
StoredFile storedFile = shareLink.getFile();
|
||||
return new FileShareDetailsResponse(
|
||||
shareLink.getToken(),
|
||||
shareLink.getOwner().getUsername(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
storedFile.isDirectory(),
|
||||
shareLink.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse importSharedFile(User recipient, String token, String path) {
|
||||
FileShareLink shareLink = getShareLink(token);
|
||||
StoredFile sourceFile = shareLink.getFile();
|
||||
if (sourceFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
|
||||
}
|
||||
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
String filename = normalizeLeafName(sourceFile.getFilename());
|
||||
validateUpload(recipient.getId(), normalizedPath, filename, sourceFile.getSize());
|
||||
ensureDirectoryHierarchy(recipient, normalizedPath);
|
||||
|
||||
byte[] content = fileContentStorage.readFile(
|
||||
sourceFile.getUser().getId(),
|
||||
sourceFile.getPath(),
|
||||
sourceFile.getStorageName()
|
||||
);
|
||||
fileContentStorage.storeImportedFile(
|
||||
recipient.getId(),
|
||||
normalizedPath,
|
||||
filename,
|
||||
sourceFile.getContentType(),
|
||||
content
|
||||
);
|
||||
|
||||
return saveFileMetadata(
|
||||
recipient,
|
||||
normalizedPath,
|
||||
filename,
|
||||
filename,
|
||||
sourceFile.getContentType(),
|
||||
sourceFile.getSize()
|
||||
);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
||||
String logicalPath = buildLogicalPath(directory);
|
||||
String archiveName = directory.getFilename() + ".zip";
|
||||
@@ -304,6 +480,11 @@ public class FileService {
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
private FileShareLink getShareLink(String token) {
|
||||
return fileShareLinkRepository.findByToken(token)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在"));
|
||||
}
|
||||
|
||||
private StoredFile getOwnedFile(User user, Long fileId, String action) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
@@ -353,6 +534,23 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureExistingDirectoryPath(Long userId, String normalizedPath) {
|
||||
if ("/".equals(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] segments = normalizedPath.substring(1).split("/");
|
||||
String currentPath = "/";
|
||||
for (String segment : segments) {
|
||||
StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在"));
|
||||
if (!directory.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录");
|
||||
}
|
||||
currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUploadFilename(String originalFilename) {
|
||||
String filename = StringUtils.cleanPath(originalFilename);
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
@@ -406,6 +604,31 @@ public class FileService {
|
||||
: storedFile.getPath() + "/" + storedFile.getFilename();
|
||||
}
|
||||
|
||||
private String buildTargetLogicalPath(String normalizedTargetPath, String filename) {
|
||||
return "/".equals(normalizedTargetPath)
|
||||
? "/" + filename
|
||||
: normalizedTargetPath + "/" + filename;
|
||||
}
|
||||
|
||||
private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) {
|
||||
if (currentPath.equals(oldLogicalPath)) {
|
||||
return newLogicalPath;
|
||||
}
|
||||
return newLogicalPath + currentPath.substring(oldLogicalPath.length());
|
||||
}
|
||||
|
||||
private StoredFile copyStoredFile(StoredFile source, String nextPath) {
|
||||
StoredFile copiedFile = new StoredFile();
|
||||
copiedFile.setUser(source.getUser());
|
||||
copiedFile.setFilename(source.getFilename());
|
||||
copiedFile.setPath(nextPath);
|
||||
copiedFile.setStorageName(source.getStorageName());
|
||||
copiedFile.setContentType(source.getContentType());
|
||||
copiedFile.setSize(source.getSize());
|
||||
copiedFile.setDirectory(source.isDirectory());
|
||||
return copiedFile;
|
||||
}
|
||||
|
||||
private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
|
||||
StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/');
|
||||
if (!storedFile.getPath().equals(rootLogicalPath)) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record FileShareDetailsResponse(
|
||||
String token,
|
||||
String ownerUsername,
|
||||
String filename,
|
||||
long size,
|
||||
String contentType,
|
||||
boolean directory,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
89
backend/src/main/java/com/yoyuzh/files/FileShareLink.java
Normal file
89
backend/src/main/java/com/yoyuzh/files/FileShareLink.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file_share_link", indexes = {
|
||||
@Index(name = "uk_file_share_token", columnList = "token", unique = true),
|
||||
@Index(name = "idx_file_share_created_at", columnList = "created_at")
|
||||
})
|
||||
public class FileShareLink {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "file_id", nullable = false)
|
||||
private StoredFile file;
|
||||
|
||||
@Column(nullable = false, length = 96, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(User owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public StoredFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public void setFile(StoredFile file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = {"owner", "file", "file.user"})
|
||||
Optional<FileShareLink> findByToken(String token);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ImportSharedFileRequest(@NotBlank String path) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MoveFileRequest(
|
||||
@NotBlank(message = "目标路径不能为空")
|
||||
String path
|
||||
) {
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
@@ -38,6 +39,14 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path and f.filename = :filename
|
||||
""")
|
||||
Optional<StoredFile> findByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CreateTransferSessionRequest(
|
||||
@NotEmpty(message = "至少选择一个文件")
|
||||
List<@Valid TransferFileItem> files
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LookupTransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PollTransferSignalsResponse(
|
||||
List<TransferSignalEnvelope> items,
|
||||
long nextCursor
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/transfer")
|
||||
@RequiredArgsConstructor
|
||||
public class TransferController {
|
||||
|
||||
private final TransferService transferService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "创建快传会话")
|
||||
@PostMapping("/sessions")
|
||||
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody CreateTransferSessionRequest request) {
|
||||
requireAuthenticatedUser(userDetails);
|
||||
userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
return ApiResponse.success(transferService.createSession(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "通过取件码查找快传会话")
|
||||
@GetMapping("/sessions/lookup")
|
||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
|
||||
return ApiResponse.success(transferService.lookupSession(pickupCode));
|
||||
}
|
||||
|
||||
@Operation(summary = "加入快传会话")
|
||||
@PostMapping("/sessions/{sessionId}/join")
|
||||
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
|
||||
return ApiResponse.success(transferService.joinSession(sessionId));
|
||||
}
|
||||
|
||||
@Operation(summary = "提交快传信令")
|
||||
@PostMapping("/sessions/{sessionId}/signals")
|
||||
public ApiResponse<Void> postSignal(@PathVariable String sessionId,
|
||||
@RequestParam String role,
|
||||
@Valid @RequestBody TransferSignalRequest request) {
|
||||
transferService.postSignal(sessionId, role, request);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "轮询快传信令")
|
||||
@GetMapping("/sessions/{sessionId}/signals")
|
||||
public ApiResponse<PollTransferSignalsResponse> pollSignals(@PathVariable String sessionId,
|
||||
@RequestParam String role,
|
||||
@RequestParam(defaultValue = "0") long after) {
|
||||
return ApiResponse.success(transferService.pollSignals(sessionId, role, after));
|
||||
}
|
||||
|
||||
private void requireAuthenticatedUser(UserDetails userDetails) {
|
||||
if (userDetails == null) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户未登录");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TransferFileItem(
|
||||
@NotBlank(message = "文件名不能为空")
|
||||
String name,
|
||||
@Min(value = 0, message = "文件大小不能为负数")
|
||||
long size,
|
||||
String contentType
|
||||
) {
|
||||
}
|
||||
21
backend/src/main/java/com/yoyuzh/transfer/TransferRole.java
Normal file
21
backend/src/main/java/com/yoyuzh/transfer/TransferRole.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
enum TransferRole {
|
||||
SENDER,
|
||||
RECEIVER;
|
||||
|
||||
static TransferRole from(String role) {
|
||||
String normalized = Objects.requireNonNullElse(role, "").trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "sender" -> SENDER;
|
||||
case "receiver" -> RECEIVER;
|
||||
default -> throw new BusinessException(ErrorCode.UNKNOWN, "不支持的传输角色");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class TransferService {
|
||||
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private final TransferSessionStore sessionStore;
|
||||
|
||||
public TransferService(TransferSessionStore sessionStore) {
|
||||
this.sessionStore = sessionStore;
|
||||
}
|
||||
|
||||
public TransferSessionResponse createSession(CreateTransferSessionRequest request) {
|
||||
pruneExpiredSessions();
|
||||
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
String pickupCode = sessionStore.nextPickupCode();
|
||||
Instant expiresAt = Instant.now().plus(SESSION_TTL);
|
||||
List<TransferFileItem> files = request.files().stream()
|
||||
.map(file -> new TransferFileItem(file.name(), file.size(), normalizeContentType(file.contentType())))
|
||||
.toList();
|
||||
|
||||
TransferSession session = new TransferSession(sessionId, pickupCode, expiresAt, files);
|
||||
sessionStore.save(session);
|
||||
return session.toSessionResponse();
|
||||
}
|
||||
|
||||
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
||||
pruneExpiredSessions();
|
||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
||||
TransferSession session = sessionStore.findByPickupCode(normalizedPickupCode)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||
return session.toLookupResponse();
|
||||
}
|
||||
|
||||
public TransferSessionResponse joinSession(String sessionId) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
session.markReceiverJoined();
|
||||
return session.toSessionResponse();
|
||||
}
|
||||
|
||||
public void postSignal(String sessionId, String role, TransferSignalRequest request) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
session.enqueue(TransferRole.from(role), request.type().trim(), request.payload().trim());
|
||||
}
|
||||
|
||||
public PollTransferSignalsResponse pollSignals(String sessionId, String role, long after) {
|
||||
pruneExpiredSessions();
|
||||
TransferSession session = getRequiredSession(sessionId);
|
||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
||||
}
|
||||
|
||||
private TransferSession getRequiredSession(String sessionId) {
|
||||
TransferSession session = sessionStore.findById(sessionId).orElse(null);
|
||||
if (session == null || session.isExpired(Instant.now())) {
|
||||
if (session != null) {
|
||||
sessionStore.remove(session);
|
||||
}
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private void pruneExpiredSessions() {
|
||||
sessionStore.pruneExpired(Instant.now());
|
||||
}
|
||||
|
||||
private String normalizePickupCode(String pickupCode) {
|
||||
String normalized = Objects.requireNonNullElse(pickupCode, "").replaceAll("\\D", "");
|
||||
if (normalized.length() != 6) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "取件码格式不正确");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeContentType(String contentType) {
|
||||
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
||||
return normalized.isEmpty() ? "application/octet-stream" : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class TransferSession {
|
||||
|
||||
private final String sessionId;
|
||||
private final String pickupCode;
|
||||
private final Instant expiresAt;
|
||||
private final List<TransferFileItem> files;
|
||||
private final List<TransferSignalEnvelope> senderQueue = new ArrayList<>();
|
||||
private final List<TransferSignalEnvelope> receiverQueue = new ArrayList<>();
|
||||
private boolean receiverJoined;
|
||||
private long nextSenderCursor = 1;
|
||||
private long nextReceiverCursor = 1;
|
||||
|
||||
TransferSession(String sessionId, String pickupCode, Instant expiresAt, List<TransferFileItem> files) {
|
||||
this.sessionId = sessionId;
|
||||
this.pickupCode = pickupCode;
|
||||
this.expiresAt = expiresAt;
|
||||
this.files = List.copyOf(files);
|
||||
}
|
||||
|
||||
synchronized TransferSessionResponse toSessionResponse() {
|
||||
return new TransferSessionResponse(sessionId, pickupCode, expiresAt, files);
|
||||
}
|
||||
|
||||
synchronized LookupTransferSessionResponse toLookupResponse() {
|
||||
return new LookupTransferSessionResponse(sessionId, pickupCode, expiresAt);
|
||||
}
|
||||
|
||||
synchronized void markReceiverJoined() {
|
||||
if (receiverJoined) {
|
||||
return;
|
||||
}
|
||||
|
||||
receiverJoined = true;
|
||||
senderQueue.add(new TransferSignalEnvelope(nextSenderCursor++, "peer-joined", "{}"));
|
||||
}
|
||||
|
||||
synchronized void enqueue(TransferRole sourceRole, String type, String payload) {
|
||||
if (sourceRole == TransferRole.SENDER) {
|
||||
receiverQueue.add(new TransferSignalEnvelope(nextReceiverCursor++, type, payload));
|
||||
return;
|
||||
}
|
||||
|
||||
senderQueue.add(new TransferSignalEnvelope(nextSenderCursor++, type, payload));
|
||||
}
|
||||
|
||||
synchronized PollTransferSignalsResponse poll(TransferRole role, long after) {
|
||||
List<TransferSignalEnvelope> queue = role == TransferRole.SENDER ? senderQueue : receiverQueue;
|
||||
List<TransferSignalEnvelope> items = queue.stream()
|
||||
.filter(item -> item.cursor() > after)
|
||||
.toList();
|
||||
long nextCursor = items.isEmpty() ? after : items.get(items.size() - 1).cursor();
|
||||
return new PollTransferSignalsResponse(items, nextCursor);
|
||||
}
|
||||
|
||||
boolean isExpired(Instant now) {
|
||||
return expiresAt.isBefore(now);
|
||||
}
|
||||
|
||||
String sessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
String pickupCode() {
|
||||
return pickupCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record TransferSessionResponse(
|
||||
String sessionId,
|
||||
String pickupCode,
|
||||
Instant expiresAt,
|
||||
List<TransferFileItem> files
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Component
|
||||
public class TransferSessionStore {
|
||||
|
||||
private final Map<String, TransferSession> sessionsById = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> sessionIdsByPickupCode = new ConcurrentHashMap<>();
|
||||
|
||||
public void save(TransferSession session) {
|
||||
sessionsById.put(session.sessionId(), session);
|
||||
sessionIdsByPickupCode.put(session.pickupCode(), session.sessionId());
|
||||
}
|
||||
|
||||
public Optional<TransferSession> findById(String sessionId) {
|
||||
return Optional.ofNullable(sessionsById.get(sessionId));
|
||||
}
|
||||
|
||||
public Optional<TransferSession> findByPickupCode(String pickupCode) {
|
||||
String sessionId = sessionIdsByPickupCode.get(pickupCode);
|
||||
if (sessionId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return findById(sessionId);
|
||||
}
|
||||
|
||||
public void remove(TransferSession session) {
|
||||
sessionsById.remove(session.sessionId(), session);
|
||||
sessionIdsByPickupCode.remove(session.pickupCode(), session.sessionId());
|
||||
}
|
||||
|
||||
public void pruneExpired(Instant now) {
|
||||
for (TransferSession session : sessionsById.values()) {
|
||||
if (session.isExpired(now)) {
|
||||
remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String nextPickupCode() {
|
||||
String pickupCode;
|
||||
do {
|
||||
pickupCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
|
||||
} while (sessionIdsByPickupCode.containsKey(pickupCode));
|
||||
return pickupCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
public record TransferSignalEnvelope(
|
||||
long cursor,
|
||||
String type,
|
||||
String payload
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TransferSignalRequest(
|
||||
@NotBlank(message = "信令类型不能为空")
|
||||
String type,
|
||||
@NotBlank(message = "信令内容不能为空")
|
||||
String payload
|
||||
) {
|
||||
}
|
||||
@@ -17,5 +17,3 @@ app:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
cqu:
|
||||
mock-enabled: true
|
||||
|
||||
@@ -32,10 +32,6 @@ app:
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
cqu:
|
||||
base-url: https://example-cqu-api.local
|
||||
require-login: true
|
||||
mock-enabled: false
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
|
||||
Reference in New Issue
Block a user