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

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

@@ -4,7 +4,7 @@
- 用户注册、登录、JWT 鉴权、用户信息接口 - 用户注册、登录、JWT 鉴权、用户信息接口
- 个人网盘上传、下载、删除、目录管理、分页列表 - 个人网盘上传、下载、删除、目录管理、分页列表
- CQU 课表与成绩聚合接口 - 快传会话与浏览器间 P2P 信令接口
- Swagger 文档、统一异常、日志输出 - Swagger 文档、统一异常、日志输出
## 环境要求 ## 环境要求
@@ -32,7 +32,6 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
`dev` 环境特点: `dev` 环境特点:
- 数据库使用 H2 文件库 - 数据库使用 H2 文件库
- CQU 接口返回 mock 数据
- 方便和 `vue/` 前端直接联调 - 方便和 `vue/` 前端直接联调
JWT 启动要求: JWT 启动要求:
@@ -54,39 +53,20 @@ JWT 启动要求:
## 旧库升级 ## 旧库升级
如果服务器数据库是按旧版脚本初始化的,需要先补齐下面这些字段,否则登录后的首页接口可能在查询用户、课表或成绩时直接报 500 如果服务器数据库是按旧版脚本初始化的,旧教务相关字段和表可以保留但不会再被当前代码使用。新环境请直接使用最新初始化脚本,不再创建教务缓存表
MySQL: MySQL:
```sql ```sql
ALTER TABLE portal_user DROP TABLE IF EXISTS portal_course;
ADD COLUMN last_school_student_id VARCHAR(64) NULL, DROP TABLE IF EXISTS portal_grade;
ADD COLUMN last_school_semester VARCHAR(64) NULL;
ALTER TABLE portal_course
ADD COLUMN semester VARCHAR(64) NULL,
ADD COLUMN student_id VARCHAR(64) NULL;
ALTER TABLE portal_grade
ADD COLUMN student_id VARCHAR(64) NULL;
CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id);
CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
``` ```
openGauss: openGauss:
```sql ```sql
ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_student_id VARCHAR(64); DROP TABLE IF EXISTS portal_course;
ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_semester VARCHAR(64); DROP TABLE IF EXISTS portal_grade;
ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS semester VARCHAR(64);
ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS student_id VARCHAR(64);
ALTER TABLE portal_grade ADD COLUMN IF NOT EXISTS student_id VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id);
CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
``` ```
## 主要接口 ## 主要接口
@@ -103,22 +83,11 @@ CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, sem
- `GET /api/files/download/{fileId}` - `GET /api/files/download/{fileId}`
- `GET /api/files/download/{fileId}/url` - `GET /api/files/download/{fileId}/url`
- `DELETE /api/files/{fileId}` - `DELETE /api/files/{fileId}`
- `GET /api/cqu/schedule` - `POST /api/transfer/sessions`
- `GET /api/cqu/grades` - `GET /api/transfer/sessions/lookup`
- `POST /api/transfer/sessions/{sessionId}/join`
## CQU 配置 - `POST /api/transfer/sessions/{sessionId}/signals`
- `GET /api/transfer/sessions/{sessionId}/signals`
部署到真实环境时修改:
```yaml
app:
cqu:
base-url: https://your-cqu-api
require-login: false
mock-enabled: false
```
当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。
## OSS 直传说明 ## OSS 直传说明

View File

@@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS portal_user (
email VARCHAR(128) NOT NULL, email VARCHAR(128) NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_school_student_id VARCHAR(64),
last_school_semester VARCHAR(64),
CONSTRAINT uk_portal_user_username UNIQUE (username), CONSTRAINT uk_portal_user_username UNIQUE (username),
CONSTRAINT uk_portal_user_email UNIQUE (email) CONSTRAINT uk_portal_user_email UNIQUE (email)
); );
@@ -27,35 +25,5 @@ CREATE TABLE IF NOT EXISTS portal_file (
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename) CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
); );
CREATE TABLE IF NOT EXISTS portal_course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
course_name VARCHAR(255) NOT NULL,
semester VARCHAR(64),
student_id VARCHAR(64),
teacher VARCHAR(255),
classroom VARCHAR(255),
day_of_week INT,
start_time INT,
end_time INT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_portal_course_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
);
CREATE TABLE IF NOT EXISTS portal_grade (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
course_name VARCHAR(255) NOT NULL,
grade DOUBLE NOT NULL,
semester VARCHAR(64) NOT NULL,
student_id VARCHAR(64),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_portal_grade_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
);
CREATE INDEX idx_user_created_at ON portal_user (created_at); CREATE INDEX idx_user_created_at ON portal_user (created_at);
CREATE INDEX idx_file_created_at ON portal_file (created_at); CREATE INDEX idx_file_created_at ON portal_file (created_at);
CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id);
CREATE INDEX idx_course_user_created ON portal_course (user_id, created_at);
CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
CREATE INDEX idx_grade_user_created ON portal_grade (user_id, created_at);

View File

@@ -3,9 +3,7 @@ CREATE TABLE IF NOT EXISTS portal_user (
username VARCHAR(64) NOT NULL UNIQUE, username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
last_school_student_id VARCHAR(64),
last_school_semester VARCHAR(64)
); );
CREATE TABLE IF NOT EXISTS portal_file ( CREATE TABLE IF NOT EXISTS portal_file (
@@ -21,33 +19,5 @@ CREATE TABLE IF NOT EXISTS portal_file (
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename) CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
); );
CREATE TABLE IF NOT EXISTS portal_course (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_user (id),
course_name VARCHAR(255) NOT NULL,
semester VARCHAR(64),
student_id VARCHAR(64),
teacher VARCHAR(255),
classroom VARCHAR(255),
day_of_week INTEGER,
start_time INTEGER,
end_time INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS portal_grade (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_user (id),
course_name VARCHAR(255) NOT NULL,
grade DOUBLE PRECISION NOT NULL,
semester VARCHAR(64) NOT NULL,
student_id VARCHAR(64),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at); CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at);
CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at); CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at);
CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id);
CREATE INDEX IF NOT EXISTS idx_course_user_created ON portal_course (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id);
CREATE INDEX IF NOT EXISTS idx_grade_user_created ON portal_grade (user_id, created_at);

View File

@@ -1,10 +1,9 @@
package com.yoyuzh; package com.yoyuzh;
import com.yoyuzh.config.CquApiProperties; import com.yoyuzh.config.AdminProperties;
import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.CorsProperties;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.config.JwtProperties; import com.yoyuzh.config.JwtProperties;
import com.yoyuzh.config.AdminProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -13,7 +12,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
@EnableConfigurationProperties({ @EnableConfigurationProperties({
JwtProperties.class, JwtProperties.class,
FileStorageProperties.class, FileStorageProperties.class,
CquApiProperties.class,
CorsProperties.class, CorsProperties.class,
AdminProperties.class AdminProperties.class
}) })

View File

@@ -50,13 +50,6 @@ public class AdminController {
return ApiResponse.success(); 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") @PatchMapping("/users/{userId}/role")
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId, public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
@Valid @RequestBody AdminUserRoleUpdateRequest request) { @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.BusinessException;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse; import com.yoyuzh.common.PageResponse;
import com.yoyuzh.cqu.CourseRepository;
import com.yoyuzh.cqu.GradeRepository;
import com.yoyuzh.files.FileService; import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository; import com.yoyuzh.files.StoredFileRepository;
@@ -31,8 +29,6 @@ public class AdminService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final StoredFileRepository storedFileRepository; private final StoredFileRepository storedFileRepository;
private final FileService fileService; private final FileService fileService;
private final CourseRepository courseRepository;
private final GradeRepository gradeRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@@ -40,8 +36,7 @@ public class AdminService {
public AdminSummaryResponse getSummary() { public AdminSummaryResponse getSummary() {
return new AdminSummaryResponse( return new AdminSummaryResponse(
userRepository.count(), userRepository.count(),
storedFileRepository.count(), storedFileRepository.count()
userRepository.countByLastSchoolStudentIdIsNotNull()
); );
} }
@@ -69,16 +64,6 @@ public class AdminService {
return new PageResponse<>(items, result.getTotalElements(), page, size); 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 @Transactional
public void deleteFile(Long fileId) { public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId) StoredFile storedFile = storedFileRepository.findById(fileId)
@@ -126,8 +111,6 @@ public class AdminService {
user.getEmail(), user.getEmail(),
user.getPhoneNumber(), user.getPhoneNumber(),
user.getCreatedAt(), user.getCreatedAt(),
user.getLastSchoolStudentId(),
user.getLastSchoolSemester(),
user.getRole(), user.getRole(),
user.isBanned() 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) { private User getRequiredUser(Long userId) {
return userRepository.findById(userId) return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在")); .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));

View File

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

View File

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

View File

@@ -40,12 +40,6 @@ public class User {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private LocalDateTime createdAt; 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) @Column(name = "display_name", nullable = false, length = 64)
private String displayName; private String displayName;
@@ -135,22 +129,6 @@ public class User {
this.createdAt = createdAt; 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() { public String getDisplayName() {
return displayName; return displayName;
} }

View File

@@ -17,10 +17,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
long countByLastSchoolStudentIdIsNotNull();
Page<User> findByLastSchoolStudentIdIsNotNull(Pageable pageable);
@Query(""" @Query("""
select u from User u select u from User u
where (:query is null or :query = '' 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
@@ -47,9 +48,13 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll() .permitAll()
.requestMatchers("/api/transfer/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")
.permitAll()
.requestMatchers("/api/admin/**") .requestMatchers("/api/admin/**")
.authenticated() .authenticated()
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**") .requestMatchers("/api/files/**", "/api/user/**")
.authenticated() .authenticated()
.anyRequest() .anyRequest()
.permitAll()) .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())); 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 = "删除文件") @Operation(summary = "删除文件")
@DeleteMapping("/{fileId}") @DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails, public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,

View File

@@ -22,10 +22,12 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -35,13 +37,16 @@ public class FileService {
private final StoredFileRepository storedFileRepository; private final StoredFileRepository storedFileRepository;
private final FileContentStorage fileContentStorage; private final FileContentStorage fileContentStorage;
private final FileShareLinkRepository fileShareLinkRepository;
private final long maxFileSize; private final long maxFileSize;
public FileService(StoredFileRepository storedFileRepository, public FileService(StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage, FileContentStorage fileContentStorage,
FileShareLinkRepository fileShareLinkRepository,
FileStorageProperties properties) { FileStorageProperties properties) {
this.storedFileRepository = storedFileRepository; this.storedFileRepository = storedFileRepository;
this.fileContentStorage = fileContentStorage; this.fileContentStorage = fileContentStorage;
this.fileShareLinkRepository = fileShareLinkRepository;
this.maxFileSize = properties.getMaxFileSize(); this.maxFileSize = properties.getMaxFileSize();
} }
@@ -207,6 +212,105 @@ public class FileService {
return toResponse(storedFileRepository.save(storedFile)); 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) { public ResponseEntity<?> download(User user, Long fileId) {
StoredFile storedFile = getOwnedFile(user, fileId, "下载"); StoredFile storedFile = getOwnedFile(user, fileId, "下载");
if (storedFile.isDirectory()) { if (storedFile.isDirectory()) {
@@ -249,6 +353,78 @@ public class FileService {
return new DownloadUrlResponse("/api/files/download/" + storedFile.getId()); 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) { private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
String logicalPath = buildLogicalPath(directory); String logicalPath = buildLogicalPath(directory);
String archiveName = directory.getFilename() + ".zip"; String archiveName = directory.getFilename() + ".zip";
@@ -304,6 +480,11 @@ public class FileService {
return toResponse(storedFileRepository.save(storedFile)); 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) { private StoredFile getOwnedFile(User user, Long fileId, String action) {
StoredFile storedFile = storedFileRepository.findById(fileId) StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); .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) { private String normalizeUploadFilename(String originalFilename) {
String filename = StringUtils.cleanPath(originalFilename); String filename = StringUtils.cleanPath(originalFilename);
if (!StringUtils.hasText(filename)) { if (!StringUtils.hasText(filename)) {
@@ -406,6 +604,31 @@ public class FileService {
: storedFile.getPath() + "/" + storedFile.getFilename(); : 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) { private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) {
StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/'); StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/');
if (!storedFile.getPath().equals(rootLogicalPath)) { 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 org.springframework.data.repository.query.Param;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> { public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@@ -38,6 +39,14 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@Param("path") String path, @Param("path") String path,
@Param("filename") String filename); @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(""" @Query("""
select f from StoredFile f select f from StoredFile f
where f.user.id = :userId and f.path = :path 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:} secret: ${APP_JWT_SECRET:}
admin: admin:
usernames: ${APP_ADMIN_USERNAMES:} usernames: ${APP_ADMIN_USERNAMES:}
cqu:
mock-enabled: true

View File

@@ -32,10 +32,6 @@ app:
storage: storage:
root-dir: ./storage root-dir: ./storage
max-file-size: 524288000 max-file-size: 524288000
cqu:
base-url: https://example-cqu-api.local
require-login: true
mock-enabled: false
cors: cors:
allowed-origins: allowed-origins:
- http://localhost:3000 - 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", "spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef", "app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.admin.usernames=admin", "app.admin.usernames=admin",
"app.storage.root-dir=./target/test-storage-admin", "app.storage.root-dir=./target/test-storage-admin"
"app.cqu.require-login=true",
"app.cqu.mock-enabled=false"
} }
) )
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -66,8 +64,6 @@ class AdminControllerIntegrationTest {
portalUser.setPhoneNumber("13800138000"); portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password"); portalUser.setPasswordHash("encoded-password");
portalUser.setCreatedAt(LocalDateTime.now()); portalUser.setCreatedAt(LocalDateTime.now());
portalUser.setLastSchoolStudentId("20230001");
portalUser.setLastSchoolSemester("2025-2026-1");
portalUser = userRepository.save(portalUser); portalUser = userRepository.save(portalUser);
secondaryUser = new User(); secondaryUser = new User();
@@ -109,15 +105,13 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].username").value("alice")) .andExpect(jsonPath("$.data.items[0].username").value("alice"))
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000")) .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].role").value("USER"))
.andExpect(jsonPath("$.data.items[0].banned").value(false)); .andExpect(jsonPath("$.data.items[0].banned").value(false));
mockMvc.perform(get("/api/admin/summary")) mockMvc.perform(get("/api/admin/summary"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalUsers").value(2)) .andExpect(jsonPath("$.data.totalUsers").value(2))
.andExpect(jsonPath("$.data.totalFiles").value(2)) .andExpect(jsonPath("$.data.totalFiles").value(2));
.andExpect(jsonPath("$.data.usersWithSchoolCache").value(1));
} }
@Test @Test

View File

@@ -28,9 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
"spring.datasource.password=", "spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop", "spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef", "app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.storage.root-dir=./target/test-storage-refresh", "app.storage.root-dir=./target/test-storage-refresh"
"app.cqu.require-login=true",
"app.cqu.mock-enabled=false"
} }
) )
class RefreshTokenServiceIntegrationTest { class RefreshTokenServiceIntegrationTest {

View File

@@ -28,4 +28,5 @@ class SecurityConfigTest {
assertThat(configuration).isNotNull(); assertThat(configuration).isNotNull();
assertThat(configuration.getAllowedMethods()).contains("PATCH"); 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -43,13 +44,16 @@ class FileServiceTest {
@Mock @Mock
private FileContentStorage fileContentStorage; private FileContentStorage fileContentStorage;
@Mock
private FileShareLinkRepository fileShareLinkRepository;
private FileService fileService; private FileService fileService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
FileStorageProperties properties = new FileStorageProperties(); FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024); properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, properties); fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties);
} }
@Test @Test
@@ -167,6 +171,140 @@ class FileServiceTest {
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile)); 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 @Test
void shouldRejectDeletingOtherUsersFile() { void shouldRejectDeletingOtherUsersFile() {
User owner = createUser(1L); User owner = createUser(1L);
@@ -293,6 +431,60 @@ class FileServiceTest {
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt"); 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) { private User createUser(Long id) {
User user = new User(); User user = new User();
user.setId(id); 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\"}"));
}
}

View File

@@ -0,0 +1,88 @@
# File Share And Transfer Save Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users create URL-based netdisk share links that another logged-in user can import into their own netdisk, and let transfer receivers save received files directly into their netdisk.
**Architecture:** Add a small share-link domain under `backend/src/main/java/com/yoyuzh/files` so the backend can issue secret share tokens, expose share metadata, and import the shared file into the recipients storage without routing the payload through the browser. On the frontend, add share actions to the Files page, a public `/share/:token` import page, and a reusable netdisk-upload helper that Transfer receive actions can call to persist downloaded blobs into the current users storage.
**Tech Stack:** Spring Boot 3.3.8 + Java 17 + JPA, React 19 + Vite + TypeScript, existing file storage abstraction, existing frontend Node test runner and Maven tests.
---
### Task 1: Define Backend Share-Link API Contract
**Files:**
- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java`
- Create: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java`
- [ ] **Step 1: Write failing tests for creating a share link, reading share metadata, and importing a shared file into another users netdisk**
- [ ] **Step 2: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test` to verify the new tests fail**
- [ ] **Step 3: Implement the minimal backend API surface to satisfy the tests**
- [ ] **Step 4: Re-run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`**
### Task 2: Add Backend Share-Link Persistence And Import Logic
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/files/FileShareLink.java`
- Create: `backend/src/main/java/com/yoyuzh/files/FileShareLinkRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/files/CreateFileShareLinkResponse.java`
- Create: `backend/src/main/java/com/yoyuzh/files/FileShareDetailsResponse.java`
- Create: `backend/src/main/java/com/yoyuzh/files/ImportSharedFileRequest.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileController.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
- [ ] **Step 1: Add the share-link entity/repository and DTOs**
- [ ] **Step 2: Extend `FileService` with share creation, share lookup, and recipient import logic**
- [ ] **Step 3: Expose authenticated create/import endpoints and a public share-details endpoint in `FileController`**
- [ ] **Step 4: Keep directory handling explicit; only add behavior required by the tests**
### Task 3: Add Frontend Share-Link Helpers And Tests
**Files:**
- Create: `front/src/lib/file-share.ts`
- Create: `front/src/lib/file-share.test.ts`
- Modify: `front/src/lib/types.ts`
- [ ] **Step 1: Write failing frontend helper tests for share-link building/parsing and import payload helpers**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Implement minimal share helper wrappers against the backend API**
- [ ] **Step 4: Re-run `cd front && npm run test`**
### Task 4: Add Public Share Import Page
**Files:**
- Create: `front/src/pages/FileShare.tsx`
- Modify: `front/src/App.tsx`
- Modify: `front/src/pages/Login.tsx`
- [ ] **Step 1: Add failing tests for any pure helper logic used by the share page and login redirect flow**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Implement `/share/:token`, showing share metadata publicly and allowing authenticated users to import into their netdisk**
- [ ] **Step 4: Add login redirect-back handling only as needed for this route**
### Task 5: Add Share Actions To Netdisk And Save-To-Netdisk For Transfer
**Files:**
- Modify: `front/src/pages/Files.tsx`
- Create: `front/src/lib/netdisk-upload.ts`
- Create: `front/src/lib/netdisk-upload.test.ts`
- Modify: `front/src/pages/TransferReceive.tsx`
- [ ] **Step 1: Write failing helper tests for saving a browser `File` into netdisk**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Add a share action in the Files page that creates/copies a share URL**
- [ ] **Step 4: Add “存入网盘” actions in transfer receive for completed files**
- [ ] **Step 5: Re-run `cd front && npm run test`**
### Task 6: Full Verification
**Files:**
- Modify only if validation reveals defects
- [ ] **Step 1: Run `cd front && npm run test`**
- [ ] **Step 2: Run `cd front && npm run lint`**
- [ ] **Step 3: Run `cd front && npm run build`**
- [ ] **Step 4: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`**
- [ ] **Step 5: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn package`**
- [ ] **Step 6: Deploy frontend with `node scripts/deploy-front-oss.mjs` only after all checks pass**

View File

@@ -0,0 +1,84 @@
# Netdisk Path Picker And Move Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users choose a destination path in a centered modal when saving files into the netdisk, and add a real move-file/move-folder capability inside the netdisk.
**Architecture:** Add a backend move endpoint in the existing `files` domain so both local storage and OSS-backed storage can relocate files safely. On the frontend, introduce a reusable netdisk path picker modal that can browse existing folders and reuse it from transfer save flows, share import flows, and the new move action in the Files page.
**Tech Stack:** Spring Boot 3.3.8 + Java 17 + JPA, React 19 + Vite + TypeScript, Tailwind CSS v4, existing file storage abstraction and Node test runner.
---
### Task 1: Add Backend Move API Contract
**Files:**
- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java`
- [ ] **Step 1: Write failing backend tests for moving a file to another directory and moving a folder while preserving descendants**
- [ ] **Step 2: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test` to verify the new tests fail**
- [ ] **Step 3: Implement the minimal backend API surface to satisfy the tests**
- [ ] **Step 4: Re-run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`**
### Task 2: Implement Backend Move Logic
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/files/MoveFileRequest.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileController.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/LocalFileContentStorage.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/OssFileContentStorage.java`
- [ ] **Step 1: Add a move request DTO and controller endpoint for `PATCH /api/files/{fileId}/move`**
- [ ] **Step 2: Extend the repository and service with destination-directory validation, duplicate-name protection, and self/descendant move guards**
- [ ] **Step 3: Add storage-layer support for moving a file across directories while reusing existing directory move behavior**
- [ ] **Step 4: Keep the implementation narrow to existing netdisk semantics: move into an existing directory only**
### Task 3: Add Frontend Path Selection Helpers And Tests
**Files:**
- Create: `front/src/lib/netdisk-paths.ts`
- Create: `front/src/lib/netdisk-paths.test.ts`
- Modify: `front/src/lib/netdisk-upload.ts`
- Modify: `front/src/lib/netdisk-upload.test.ts`
- [ ] **Step 1: Write failing helper tests for netdisk path splitting/joining and default transfer save paths**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Implement minimal shared path helpers for the picker modal and save flows**
- [ ] **Step 4: Re-run `cd front && npm run test`**
### Task 4: Add Reusable Netdisk Path Picker Modal
**Files:**
- Create: `front/src/components/ui/NetdiskPathPickerModal.tsx`
- Modify: `front/src/pages/FileShare.tsx`
- Modify: `front/src/pages/TransferReceive.tsx`
- [ ] **Step 1: Replace inline save/import path entry with a centered modal path picker that browses existing folders**
- [ ] **Step 2: Reuse the same modal for transfer “存入网盘” and share import so the interaction stays consistent**
- [ ] **Step 3: Keep browsing lightweight by listing one directory level at a time and filtering to folders only**
### Task 5: Add Netdisk Move UI
**Files:**
- Modify: `front/src/pages/Files.tsx`
- Create only if needed: `front/src/lib/file-move.ts`
- [ ] **Step 1: Add a move action to the file list menu and detail sidebar**
- [ ] **Step 2: Reuse the path picker modal to choose the destination directory**
- [ ] **Step 3: Call the backend move endpoint, refresh the current listing, and clear or sync selection as needed**
- [ ] **Step 4: Surface move errors in the modal instead of failing silently**
### Task 6: Full Verification
**Files:**
- Modify only if validation reveals defects
- [ ] **Step 1: Run `cd front && npm run test`**
- [ ] **Step 2: Run `cd front && npm run lint`**
- [ ] **Step 3: Run `cd front && npm run build`**
- [ ] **Step 4: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`**
- [ ] **Step 5: Run `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn package`**

View File

@@ -0,0 +1,137 @@
# Transfer Module Refactor Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor the fast-transfer module so the website has one coherent transfer flow, with cleaner frontend boundaries, a cleaner backend session model, and route generation that matches the site router mode.
**Architecture:** Keep the current product behavior of “authenticated sender page + public receiver page + backend signaling only”, but split protocol/state concerns away from route UI. On the frontend, centralize transfer URL building, protocol message helpers, and sender/receiver session orchestration so the pages become thinner. On the backend, split the current monolithic in-memory service into small transfer domain objects while preserving the same HTTP API.
**Tech Stack:** Vite 6, React 19, TypeScript, Spring Boot 3.3, Java 17, node:test, JUnit 5, MockMvc.
---
### Task 1: Lock down route-aware transfer URLs
**Files:**
- Modify: `front/src/pages/transfer-state.test.ts`
- Modify: `front/src/pages/transfer-state.ts`
- Modify: `front/src/App.tsx`
- [ ] **Step 1: Write the failing test**
Add tests asserting:
- browser mode share URL => `https://host/t?session=abc`
- hash mode share URL => `https://host/#/t?session=abc`
- [ ] **Step 2: Run test to verify it fails**
Run: `cd front && npm run test`
- [ ] **Step 3: Write minimal implementation**
Introduce a router-mode-aware URL builder and update the app router to respect `VITE_ROUTER_MODE`.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd front && npm run test`
### Task 2: Extract shared frontend transfer protocol and session helpers
**Files:**
- Create: `front/src/lib/transfer-protocol.ts`
- Create: `front/src/lib/transfer-runtime.ts`
- Modify: `front/src/lib/transfer.ts`
- Modify: `front/src/pages/Transfer.tsx`
- Modify: `front/src/pages/TransferReceive.tsx`
- [ ] **Step 1: Write the failing test**
Add tests for pure protocol helpers:
- sender meta message encoding
- receiver payload parsing
- progress/URL helpers that no longer live inside page components
- [ ] **Step 2: Run test to verify it fails**
Run: `cd front && npm run test`
- [ ] **Step 3: Write minimal implementation**
Move WebRTC protocol constants, message parsing/encoding, and repeated session setup logic out of page components. Keep pages focused on route UI and user actions.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd front && npm run test`
### Task 3: Thin down backend transfer service
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferRole.java`
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferSession.java`
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java`
- Modify: `backend/src/main/java/com/yoyuzh/transfer/TransferService.java`
- Add/Modify Test: `backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java`
- [ ] **Step 1: Write the failing test**
Add focused tests that lock current session behavior:
- pickup code validation
- receiver join only emits one `peer-joined`
- signals route to the opposite queue
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`
- [ ] **Step 3: Write minimal implementation**
Extract session state and store responsibilities from `TransferService`, leaving `TransferService` as orchestration only.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`
### Task 4: Reconnect the module cleanly to the site
**Files:**
- Modify: `front/src/pages/Overview.tsx`
- Modify: `front/src/components/layout/Layout.tsx`
- Modify: `scripts/oss-deploy-lib.mjs`
- Modify: `scripts/oss-deploy-lib.test.mjs`
- [ ] **Step 1: Verify transfer entry points still match the refactored routes**
Confirm overview CTA, sidebar nav, and public receiver route all align on the same URL helpers.
- [ ] **Step 2: Verify the deployment aliases still cover the public transfer route**
Run: `node scripts/oss-deploy-lib.test.mjs`
- [ ] **Step 3: Apply any minimal cleanup**
Remove duplicated hardcoded route strings if they remain.
### Task 5: Full verification
**Files:**
- No code changes required unless failures appear
- [ ] **Step 1: Run frontend tests**
Run: `cd front && npm run test`
- [ ] **Step 2: Run frontend typecheck**
Run: `cd front && npm run lint`
- [ ] **Step 3: Run frontend build**
Run: `cd front && npm run build`
- [ ] **Step 4: Run backend tests**
Run: `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn test`
- [ ] **Step 5: Run backend package**
Run: `cd backend && /Users/mac/.local/tools/apache-maven-3.9.11/bin/mvn package`

View File

@@ -0,0 +1,87 @@
# Transfer WebRTC Share Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the current mock transfer page into a real QR-to-webpage sharing flow where a sender opens `/transfer`, the receiver opens a public share URL, and the browsers exchange files over WebRTC P2P.
**Architecture:** Add a minimal backend signaling layer under `backend/src/main/java/com/yoyuzh/transfer` using in-memory session storage with short TTL. Keep the sender workspace inside the authenticated portal, add a public receiver route in the frontend, and exchange SDP / ICE over authenticated-or-public HTTP endpoints while the actual file bytes move through `RTCDataChannel`.
**Tech Stack:** React 19 + Vite + TypeScript, Spring Boot 3.3.8 + Java 17, WebRTC `RTCPeerConnection`, existing OSS deploy script, Maven tests, Node test runner.
---
### Task 1: Define Share URL And Pure Frontend Protocol Helpers
**Files:**
- Modify: `front/src/pages/transfer-state.ts`
- Modify: `front/src/pages/transfer-state.test.ts`
- Modify: `front/src/App.tsx`
- [ ] **Step 1: Write failing tests for share URL and protocol helpers**
- [ ] **Step 2: Run `cd front && npm run test` to verify the new tests fail**
- [ ] **Step 3: Implement minimal helpers for public share URLs, protocol message typing, and code parsing**
- [ ] **Step 4: Run `cd front && npm run test` to verify the helpers pass**
### Task 2: Add Backend Signaling Session APIs
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java`
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferService.java`
- Create: `backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java`
- Create: `backend/src/main/java/com/yoyuzh/transfer/*.java` DTOs for create/join/poll/post signal
- Modify: `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java`
- Test: `backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java`
- Test: `backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java`
- [ ] **Step 1: Write failing backend integration tests for session creation, public join, offer/answer exchange, ICE polling, and access rules**
- [ ] **Step 2: Run `cd backend && mvn test` to verify the transfer tests fail for the expected missing endpoints**
- [ ] **Step 3: Implement the minimal in-memory signaling service and public `/api/transfer/**` endpoints**
- [ ] **Step 4: Run `cd backend && mvn test` to verify backend green**
### Task 3: Replace Mock Transfer UI With Sender Workspace
**Files:**
- Modify: `front/src/pages/Transfer.tsx`
- Create: `front/src/lib/transfer-client.ts` if needed for request wrappers
- Test: `front/src/pages/transfer-state.test.ts`
- [ ] **Step 1: Add failing tests for sender-side state transitions that now depend on created share sessions instead of mock codes**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Implement sender-side session creation, QR/share URL generation, and WebRTC offer / data channel sending**
- [ ] **Step 4: Run `cd front && npm run test` to verify green**
### Task 4: Add Public Receiver Page
**Files:**
- Create: `front/src/pages/TransferReceive.tsx`
- Modify: `front/src/App.tsx`
- Modify: `front/src/pages/Transfer.tsx`
- [ ] **Step 1: Add failing tests for public share route parsing or receiver helper logic**
- [ ] **Step 2: Run `cd front && npm run test` to verify failure**
- [ ] **Step 3: Implement the public receiver page, session join flow, answer/ICE exchange, and browser download assembly**
- [ ] **Step 4: Run `cd front && npm run test` to verify green**
### Task 5: Make OSS Publish Recognize Public Share Routes
**Files:**
- Modify: `scripts/oss-deploy-lib.mjs`
- Modify: `scripts/oss-deploy-lib.test.mjs`
- [ ] **Step 1: Write failing tests for new SPA aliases such as `t` or `transfer/receive`**
- [ ] **Step 2: Run `node scripts/oss-deploy-lib.test.mjs` only if already used elsewhere; otherwise verify through existing frontend build and test coverage**
- [ ] **Step 3: Implement the minimal alias updates**
- [ ] **Step 4: Re-run the relevant checked-in verification command**
### Task 6: Full Verification And Release
**Files:**
- Modify only if verification reveals issues
- [ ] **Step 1: Run `cd front && npm run test`**
- [ ] **Step 2: Run `cd front && npm run lint`**
- [ ] **Step 3: Run `cd front && npm run build`**
- [ ] **Step 4: Run `cd backend && mvn test`**
- [ ] **Step 5: Run `cd backend && mvn package`**
- [ ] **Step 6: Deploy frontend with `node scripts/deploy-front-oss.mjs`**
- [ ] **Step 7: Deploy backend jar to the discovered production host and restart `my-site-api.service` using the real server procedure**

View File

@@ -1,5 +1,5 @@
{ {
"name": "Personal Portal", "name": "Personal Portal",
"description": "A unified personal portal for managing files, school schedules, grades, and games with a glassmorphism design.", "description": "A unified personal portal for managing files, fast transfer, and games with a glassmorphism design.",
"requestFramePermissions": [] "requestFramePermissions": []
} }

View File

@@ -1,19 +1,34 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/layout/Layout'; import { Layout } from './components/layout/Layout';
import { useAuth } from './auth/AuthProvider'; import { useAuth } from './auth/AuthProvider';
import Login from './pages/Login'; import Login from './pages/Login';
import Overview from './pages/Overview'; import Overview from './pages/Overview';
import Files from './pages/Files'; import Files from './pages/Files';
import School from './pages/School'; import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import Games from './pages/Games'; import Games from './pages/Games';
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
import {
getTransferRouterMode,
LEGACY_PUBLIC_TRANSFER_ROUTE,
PUBLIC_TRANSFER_ROUTE,
} from './lib/transfer-links';
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp')); const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
function LegacyTransferRedirect() {
const location = useLocation();
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
}
function AppRoutes() { function AppRoutes() {
const { ready, session } = useAuth(); const { ready, session } = useAuth();
const location = useLocation();
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
if (!ready) { if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#07101D] text-slate-300"> <div className="min-h-screen flex items-center justify-center bg-[#07101D] text-slate-300">
... ...
@@ -25,6 +40,12 @@ function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route
path={PUBLIC_TRANSFER_ROUTE}
element={isAuthenticated ? <Layout><Transfer /></Layout> : <Transfer />}
/>
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<FileShare />} />
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
<Route <Route
path="/login" path="/login"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Login />} element={isAuthenticated ? <Navigate to="/overview" replace /> : <Login />}
@@ -36,7 +57,6 @@ function AppRoutes() {
<Route index element={<Navigate to="/overview" replace />} /> <Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} /> <Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} /> <Route path="files" element={<Files />} />
<Route path="school" element={<School />} />
<Route path="games" element={<Games />} /> <Route path="games" element={<Games />} />
</Route> </Route>
<Route <Route
@@ -66,9 +86,11 @@ function AppRoutes() {
} }
export default function App() { export default function App() {
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
return ( return (
<BrowserRouter> <Router>
<AppRoutes /> <AppRoutes />
</BrowserRouter> </Router>
); );
} }

View File

@@ -1,6 +1,5 @@
import FolderOutlined from '@mui/icons-material/FolderOutlined'; import FolderOutlined from '@mui/icons-material/FolderOutlined';
import GroupsOutlined from '@mui/icons-material/GroupsOutlined'; import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
import SchoolOutlined from '@mui/icons-material/SchoolOutlined';
import { Admin, Resource } from 'react-admin'; import { Admin, Resource } from 'react-admin';
import { portalAdminAuthProvider } from './auth-provider'; import { portalAdminAuthProvider } from './auth-provider';
@@ -8,7 +7,6 @@ import { portalAdminDataProvider } from './data-provider';
import { PortalAdminDashboard } from './dashboard'; import { PortalAdminDashboard } from './dashboard';
import { PortalAdminFilesList } from './files-list'; import { PortalAdminFilesList } from './files-list';
import { PortalAdminUsersList } from './users-list'; import { PortalAdminUsersList } from './users-list';
import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list';
export default function PortalAdminApp() { export default function PortalAdminApp() {
return ( return (
@@ -35,13 +33,6 @@ export default function PortalAdminApp() {
options={{ label: '文件资源' }} options={{ label: '文件资源' }}
recordRepresentation="filename" recordRepresentation="filename"
/> />
<Resource
name="schoolSnapshots"
icon={SchoolOutlined}
list={PortalAdminSchoolSnapshotsList}
options={{ label: '教务缓存' }}
recordRepresentation="username"
/>
</Admin> </Admin>
); );
} }

View File

@@ -17,12 +17,12 @@ const DASHBOARD_ITEMS = [
}, },
{ {
title: '用户管理', title: '用户管理',
description: '已接入 /api/admin/users可查看用户、邮箱与最近教务缓存标记。', description: '已接入 /api/admin/users可查看账号、邮箱、手机号与权限状态。',
status: 'connected', status: 'connected',
}, },
{ {
title: '教务快照', title: '门户运营',
description: '已接入 /api/admin/school-snapshots可查看最近学号、学期和缓存条数。', description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。',
status: 'connected', status: 'connected',
}, },
]; ];
@@ -147,9 +147,6 @@ export function PortalAdminDashboard() {
<Typography color="text.secondary"> <Typography color="text.secondary">
{state.summary?.totalFiles ?? 0} {state.summary?.totalFiles ?? 0}
</Typography> </Typography>
<Typography color="text.secondary">
{state.summary?.usersWithSchoolCache ?? 0}
</Typography>
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -76,17 +76,6 @@ test('buildAdminListPath maps generic admin resources to backend paging queries'
}), }),
'/admin/users?page=1&size=20', '/admin/users?page=1&size=20',
); );
assert.equal(
buildAdminListPath('schoolSnapshots', {
pagination: {
page: 1,
perPage: 50,
},
filter: {},
}),
'/admin/school-snapshots?page=0&size=50',
);
}); });
test('buildAdminListPath includes the user search query when present', () => { test('buildAdminListPath includes the user search query when present', () => {
@@ -103,3 +92,17 @@ test('buildAdminListPath includes the user search query when present', () => {
'/admin/users?page=0&size=25&query=alice', '/admin/users?page=0&size=25&query=alice',
); );
}); });
test('buildAdminListPath rejects the removed school snapshots resource', () => {
assert.throws(
() =>
buildAdminListPath('schoolSnapshots', {
pagination: {
page: 1,
perPage: 50,
},
filter: {},
}),
/schoolSnapshots/,
);
});

View File

@@ -3,21 +3,19 @@ import type { DataProvider, GetListParams, GetListResult, Identifier } from 'rea
import { apiRequest } from '@/src/lib/api'; import { apiRequest } from '@/src/lib/api';
import type { import type {
AdminFile, AdminFile,
AdminSchoolSnapshot,
AdminUser, AdminUser,
PageResponse, PageResponse,
} from '@/src/lib/types'; } from '@/src/lib/types';
const FILES_RESOURCE = 'files'; const FILES_RESOURCE = 'files';
const USERS_RESOURCE = 'users'; const USERS_RESOURCE = 'users';
const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots';
function createUnsupportedError(resource: string, action: string) { function createUnsupportedError(resource: string, action: string) {
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`); return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
} }
function ensureSupportedResource(resource: string, action: string) { function ensureSupportedResource(resource: string, action: string) {
if (![FILES_RESOURCE, USERS_RESOURCE, SCHOOL_SNAPSHOTS_RESOURCE].includes(resource)) { if (![FILES_RESOURCE, USERS_RESOURCE].includes(resource)) {
throw createUnsupportedError(resource, action); throw createUnsupportedError(resource, action);
} }
} }
@@ -35,10 +33,6 @@ export function buildAdminListPath(resource: string, params: Pick<GetListParams,
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`; return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
} }
if (resource === SCHOOL_SNAPSHOTS_RESOURCE) {
return `/admin/school-snapshots?page=${page}&size=${size}`;
}
throw createUnsupportedError(resource, 'list'); throw createUnsupportedError(resource, 'list');
} }
@@ -92,11 +86,7 @@ export const portalAdminDataProvider: DataProvider = {
} as GetListResult; } as GetListResult;
} }
const payload = await apiRequest<PageResponse<AdminSchoolSnapshot>>(buildAdminListPath(resource, params)); throw createUnsupportedError(resource, 'list');
return {
data: payload.items,
total: payload.total,
} as GetListResult;
}, },
getOne: async (resource) => { getOne: async (resource) => {
ensureSupportedResource(resource, 'getOne'); ensureSupportedResource(resource, 'getOne');

View File

@@ -1,22 +0,0 @@
import { Datagrid, List, NumberField, TextField } from 'react-admin';
export function PortalAdminSchoolSnapshotsList() {
return (
<List
perPage={25}
resource="schoolSnapshots"
title="教务缓存"
sort={{ field: 'id', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<TextField source="userId" label="用户 ID" />
<TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" />
<TextField source="studentId" label="学号" emptyText="-" />
<TextField source="semester" label="学期" emptyText="-" />
<NumberField source="scheduleCount" label="课表数" />
<NumberField source="gradeCount" label="成绩数" />
</Datagrid>
</List>
);
}

View File

@@ -177,8 +177,6 @@ export function PortalAdminUsersList() {
/> />
)} )}
/> />
<TextField source="lastSchoolStudentId" label="最近学号" emptyText="-" />
<TextField source="lastSchoolSemester" label="最近学期" emptyText="-" />
<DateField source="createdAt" label="创建时间" showTime /> <DateField source="createdAt" label="创建时间" showTime />
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} /> <FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
</Datagrid> </Datagrid>

View File

@@ -9,7 +9,6 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
const request = async () => ({ const request = async () => ({
totalUsers: 1, totalUsers: 1,
totalFiles: 2, totalFiles: 2,
usersWithSchoolCache: 3,
}); });
await assert.doesNotReject(async () => { await assert.doesNotReject(async () => {

View File

@@ -3,6 +3,14 @@ import test from 'node:test';
import { getVisibleNavItems } from './Layout'; import { getVisibleNavItems } from './Layout';
test('getVisibleNavItems exposes the transfer entry instead of the school entry', () => {
const visibleItems = getVisibleNavItems(false);
const visiblePaths: string[] = visibleItems.map((item) => item.path);
assert.equal(visiblePaths.includes('/transfer'), true);
assert.equal(visiblePaths.some((path) => path === '/school'), false);
});
test('getVisibleNavItems hides the admin entry for non-admin users', () => { test('getVisibleNavItems hides the admin entry for non-admin users', () => {
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false); assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
}); });

View File

@@ -1,13 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { import {
Gamepad2, Gamepad2,
FolderOpen, FolderOpen,
GraduationCap,
Key, Key,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Mail, Mail,
Send,
Settings, Settings,
Shield, Shield,
Smartphone, Smartphone,
@@ -28,7 +28,7 @@ import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './acc
const NAV_ITEMS = [ const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen }, { name: '网盘', path: '/files', icon: FolderOpen },
{ name: '教务', path: '/school', icon: GraduationCap }, { name: '快传', path: '/transfer', icon: Send },
{ name: '游戏', path: '/games', icon: Gamepad2 }, { name: '游戏', path: '/games', icon: Gamepad2 },
{ name: '后台', path: '/admin', icon: Shield }, { name: '后台', path: '/admin', icon: Shield },
] as const; ] as const;
@@ -39,7 +39,11 @@ export function getVisibleNavItems(isAdmin: boolean) {
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin'); return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
} }
export function Layout() { interface LayoutProps {
children?: ReactNode;
}
export function Layout({ children }: LayoutProps = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin, logout, refreshProfile, user } = useAuth(); const { isAdmin, logout, refreshProfile, user } = useAuth();
const navItems = getVisibleNavItems(isAdmin); const navItems = getVisibleNavItems(isAdmin);
@@ -328,7 +332,7 @@ export function Layout() {
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" /> <div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
</div> </div>
<header className="sticky top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl"> <header className="fixed top-0 left-0 right-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between"> <div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
@@ -427,8 +431,8 @@ export function Layout() {
</div> </div>
</header> </header>
<main className="flex-1 container mx-auto px-4 py-8 relative z-10"> <main className="flex-1 container mx-auto px-4 pt-24 pb-8 relative z-10">
<Outlet /> {children ?? <Outlet />}
</main> </main>
<AnimatePresence> <AnimatePresence>

View File

@@ -0,0 +1,234 @@
import React, { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Folder, Loader2, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { apiRequest } from '@/src/lib/api';
import { getParentNetdiskPath, joinNetdiskPath, splitNetdiskPath } from '@/src/lib/netdisk-paths';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import { Button } from './button';
interface NetdiskPathPickerModalProps {
isOpen: boolean;
title: string;
description?: string;
initialPath?: string;
confirmLabel: string;
confirmPathPreview?: (path: string) => string;
onClose: () => void;
onConfirm: (path: string) => Promise<void>;
}
export function NetdiskPathPickerModal({
isOpen,
title,
description,
initialPath = '/',
confirmLabel,
confirmPathPreview,
onClose,
onConfirm,
}: NetdiskPathPickerModalProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [folders, setFolders] = useState<FileMetadata[]>([]);
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!isOpen) {
return;
}
setCurrentPath(initialPath);
setError('');
}, [initialPath, isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
let active = true;
setLoading(true);
setError('');
void apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(currentPath)}&page=0&size=100`,
)
.then((response) => {
if (!active) {
return;
}
setFolders(response.items.filter((item) => item.directory));
})
.catch((requestError) => {
if (!active) {
return;
}
setFolders([]);
setError(requestError instanceof Error ? requestError.message : '读取网盘目录失败');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [currentPath, isOpen]);
async function handleConfirm() {
setConfirming(true);
setError('');
try {
await onConfirm(currentPath);
onClose();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '保存目录失败');
} finally {
setConfirming(false);
}
}
const pathSegments = splitNetdiskPath(currentPath);
const previewPath = confirmPathPreview ? confirmPathPreview(currentPath) : currentPath;
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<AnimatePresence>
{isOpen ? (
<div className="fixed inset-0 z-[130] overflow-y-auto bg-black/50 p-4 backdrop-blur-sm sm:p-6">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="mx-auto my-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0f172a] shadow-2xl sm:my-8 max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)]"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
{description ? <p className="mt-1 text-xs text-slate-400">{description}</p> : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-5">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500"></p>
<div className="mt-2 flex flex-wrap items-center gap-1 text-sm text-slate-200">
<button
type="button"
className="rounded px-1 py-0.5 hover:bg-white/10"
onClick={() => setCurrentPath('/')}
>
</button>
{pathSegments.map((segment, index) => (
<React.Fragment key={`${segment}-${index}`}>
<ChevronRight className="h-3.5 w-3.5 text-slate-500" />
<button
type="button"
className="rounded px-1 py-0.5 hover:bg-white/10"
onClick={() => setCurrentPath(joinNetdiskPath(pathSegments.slice(0, index + 1)))}
>
{segment}
</button>
</React.Fragment>
))}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="border-white/10 text-slate-200 hover:bg-white/10"
disabled={currentPath === '/'}
onClick={() => setCurrentPath(getParentNetdiskPath(currentPath))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
</div>
<p className="mt-3 text-xs text-emerald-300">: {previewPath}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/20">
<div className="border-b border-white/10 px-4 py-3 text-sm font-medium text-slate-200"></div>
<div className="max-h-72 overflow-y-auto p-3 sm:max-h-80">
{loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : folders.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-slate-500">使</div>
) : (
<div className="space-y-2">
{folders.map((folder) => {
const nextPath = folder.path;
return (
<button
key={folder.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] px-4 py-3 text-left transition-colors hover:border-white/10 hover:bg-white/[0.06]"
onClick={() => setCurrentPath(nextPath)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#336EFF]/10">
<Folder className="h-4 w-4 text-[#336EFF]" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-100">{folder.filename}</p>
<p className="truncate text-xs text-slate-500">{nextPath}</p>
</div>
<ChevronRight className="h-4 w-4 text-slate-500" />
</button>
);
})}
</div>
)}
</div>
</div>
{error ? (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">{error}</div>
) : null}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="border-white/10 text-slate-300 hover:bg-white/10" onClick={onClose} disabled={confirming}>
</Button>
<Button type="button" onClick={() => void handleConfirm()} disabled={confirming || loading}>
{confirming ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
confirmLabel
)}
</Button>
</div>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
,
document.body,
);
}

View File

@@ -60,7 +60,7 @@ test('scoped cache key includes current user identity', () => {
}, },
}); });
assert.equal(buildScopedCacheKey('school', '2023123456', '2025-spring'), 'portal-cache:user:7:school:2023123456:2025-spring'); assert.equal(buildScopedCacheKey('transfer', 'pickup-code', '849201'), 'portal-cache:user:7:transfer:pickup-code:849201');
}); });
test('cached values are isolated between users', () => { test('cached values are isolated between users', () => {
@@ -73,9 +73,9 @@ test('cached values are isolated between users', () => {
createdAt: '2026-03-14T12:00:00', createdAt: '2026-03-14T12:00:00',
}, },
}); });
writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), { writeCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201'), {
queried: true, queried: true,
grades: [95], sharedFiles: [2],
}); });
saveStoredSession({ saveStoredSession({
@@ -88,12 +88,12 @@ test('cached values are isolated between users', () => {
}, },
}); });
assert.equal(readCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring')), null); assert.equal(readCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201')), null);
}); });
test('invalid cached json is ignored safely', () => { test('invalid cached json is ignored safely', () => {
localStorage.setItem('portal-cache:user:7:school:2023123456:2025-spring', '{broken-json'); localStorage.setItem('portal-cache:user:7:transfer:pickup-code:849201', '{broken-json');
assert.equal(readCachedValue('portal-cache:user:7:school:2023123456:2025-spring'), null); assert.equal(readCachedValue('portal-cache:user:7:transfer:pickup-code:849201'), null);
assert.equal(localStorage.getItem('portal-cache:user:7:school:2023123456:2025-spring'), null); assert.equal(localStorage.getItem('portal-cache:user:7:transfer:pickup-code:849201'), null);
}); });

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function copyFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/copy`, {
method: 'POST',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function moveFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/move`, {
method: 'PATCH',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildFileShareUrl,
FILE_SHARE_ROUTE_PREFIX,
getPostLoginRedirectPath,
} from './file-share';
test('buildFileShareUrl builds a browser-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz', 'share-token-1', 'browser'),
'https://yoyuzh.xyz/share/share-token-1',
);
});
test('buildFileShareUrl builds a hash-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz/', 'share-token-1', 'hash'),
'https://yoyuzh.xyz/#/share/share-token-1',
);
});
test('getPostLoginRedirectPath keeps safe in-site paths only', () => {
assert.equal(getPostLoginRedirectPath('/share/share-token-1'), '/share/share-token-1');
assert.equal(getPostLoginRedirectPath('https://evil.example.com'), '/overview');
assert.equal(getPostLoginRedirectPath(null), '/overview');
});
test('FILE_SHARE_ROUTE_PREFIX stays aligned with the public share route', () => {
assert.equal(FILE_SHARE_ROUTE_PREFIX, '/share');
});

View File

@@ -0,0 +1,49 @@
import { apiRequest } from './api';
import { getTransferRouterMode, type TransferRouterMode } from './transfer-links';
import type { CreateFileShareLinkResponse, FileMetadata, FileShareDetailsResponse } from './types';
export const FILE_SHARE_ROUTE_PREFIX = '/share';
export function buildFileShareUrl(
origin: string,
token: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedToken = encodeURIComponent(token);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
return `${normalizedOrigin}${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
export function getPostLoginRedirectPath(nextPath: string | null, fallback = '/overview') {
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
return fallback;
}
return nextPath;
}
export function createFileShareLink(fileId: number) {
return apiRequest<CreateFileShareLinkResponse>(`/files/${fileId}/share-links`, {
method: 'POST',
});
}
export function getFileShareDetails(token: string) {
return apiRequest<FileShareDetailsResponse>(`/files/share-links/${encodeURIComponent(token)}`);
}
export function importSharedFile(token: string, path: string) {
return apiRequest<FileMetadata>(`/files/share-links/${encodeURIComponent(token)}/import`, {
method: 'POST',
body: { path },
});
}
export function getCurrentFileShareUrl(token: string) {
return buildFileShareUrl(window.location.origin, token, getTransferRouterMode());
}

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getParentNetdiskPath,
joinNetdiskPath,
resolveTransferSaveDirectory,
splitNetdiskPath,
} from './netdisk-paths';
test('splitNetdiskPath normalizes root and nested paths', () => {
assert.deepEqual(splitNetdiskPath('/'), []);
assert.deepEqual(splitNetdiskPath('/下载/旅行/照片'), ['下载', '旅行', '照片']);
assert.deepEqual(splitNetdiskPath('下载//旅行/照片/'), ['下载', '旅行', '照片']);
});
test('joinNetdiskPath rebuilds a normalized absolute path', () => {
assert.equal(joinNetdiskPath([]), '/');
assert.equal(joinNetdiskPath(['下载', '旅行']), '/下载/旅行');
});
test('getParentNetdiskPath returns the previous directory level', () => {
assert.equal(getParentNetdiskPath('/下载/旅行'), '/下载');
assert.equal(getParentNetdiskPath('/下载'), '/');
});
test('resolveTransferSaveDirectory keeps nested transfer folders under the selected root path', () => {
assert.equal(resolveTransferSaveDirectory('相册/旅行/cover.jpg', '/下载'), '/下载/相册/旅行');
assert.equal(resolveTransferSaveDirectory('cover.jpg', '/下载'), '/下载');
});

View File

@@ -0,0 +1,31 @@
export function splitNetdiskPath(path: string | null | undefined) {
const rawPath = path?.trim();
if (!rawPath || rawPath === '/') {
return [] as string[];
}
return rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
export function joinNetdiskPath(segments: string[]) {
return segments.length === 0 ? '/' : `/${segments.join('/')}`;
}
export function getParentNetdiskPath(path: string | null | undefined) {
const segments = splitNetdiskPath(path);
return joinNetdiskPath(segments.slice(0, -1));
}
export function resolveTransferSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
const rootSegments = splitNetdiskPath(rootPath);
const relativeSegments = splitNetdiskPath(relativePath);
if (relativeSegments.length <= 1) {
return joinNetdiskPath(rootSegments);
}
return joinNetdiskPath([...rootSegments, ...relativeSegments.slice(0, -1)]);
}

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeNetdiskTargetPath, resolveNetdiskSaveDirectory } from './netdisk-upload';
test('normalizeNetdiskTargetPath falls back to 下载 for blank paths', () => {
assert.equal(normalizeNetdiskTargetPath(undefined), '/下载');
assert.equal(normalizeNetdiskTargetPath(''), '/下载');
assert.equal(normalizeNetdiskTargetPath(' '), '/下载');
});
test('normalizeNetdiskTargetPath normalizes slash and root input', () => {
assert.equal(normalizeNetdiskTargetPath('/'), '/');
assert.equal(normalizeNetdiskTargetPath('下载/快传'), '/下载/快传');
assert.equal(normalizeNetdiskTargetPath('/下载/快传/'), '/下载/快传');
});
test('resolveNetdiskSaveDirectory keeps nested transfer folders under 下载', () => {
assert.equal(resolveNetdiskSaveDirectory('相册/旅行/cover.jpg'), '/下载/相册/旅行');
assert.equal(resolveNetdiskSaveDirectory('cover.jpg'), '/下载');
});
test('resolveNetdiskSaveDirectory ignores unsafe path segments', () => {
assert.equal(resolveNetdiskSaveDirectory('../相册//旅行/cover.jpg'), '/下载/相册/旅行');
});

View File

@@ -0,0 +1,60 @@
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths';
import type { FileMetadata, InitiateUploadResponse } from './types';
export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') {
const rawPath = path?.trim();
if (!rawPath) {
return fallback;
}
return joinNetdiskPath(splitNetdiskPath(rawPath === '/' ? '/' : rawPath)) || fallback;
}
export function resolveNetdiskSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
return normalizeNetdiskTargetPath(resolveTransferSaveDirectory(relativePath, rootPath));
}
export async function saveFileToNetdisk(file: File, path: string) {
const normalizedPath = normalizeNetdiskTargetPath(path);
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
contentType: file.type || null,
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
return await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
storageName: initiated.storageName,
contentType: file.type || null,
size: file.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
}
}
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, {
body: formData,
});
}

View File

@@ -1,41 +1,10 @@
import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache'; import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache';
import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types'; import type { FileMetadata, UserProfile } from './types';
export interface SchoolQueryCache {
studentId: string;
semester: string;
}
export interface SchoolResultsCache {
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
studentId: string;
semester: string;
}
export interface OverviewCache { export interface OverviewCache {
profile: UserProfile | null; profile: UserProfile | null;
recentFiles: FileMetadata[]; recentFiles: FileMetadata[];
rootFiles: FileMetadata[]; rootFiles: FileMetadata[];
schedule: CourseResponse[];
grades: GradeResponse[];
}
function getSchoolQueryCacheKey() {
return buildScopedCacheKey('school-query');
}
export function readStoredSchoolQuery() {
return readCachedValue<SchoolQueryCache>(getSchoolQueryCacheKey());
}
export function writeStoredSchoolQuery(query: SchoolQueryCache) {
writeCachedValue(getSchoolQueryCacheKey(), query);
}
export function getSchoolResultsCacheKey(studentId: string, semester: string) {
return buildScopedCacheKey('school-results', studentId, semester);
} }
export function getOverviewCacheKey() { export function getOverviewCacheKey() {

View File

@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { CourseResponse } from './types';
import { buildScheduleTable, getScheduleCellHeight, getScheduleDividerOffsets } from './schedule-table';
test('buildScheduleTable creates 12 sections with empty slots preserved', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Advanced Java',
teacher: 'Li',
classroom: 'A101',
dayOfWeek: 1,
startTime: 1,
endTime: 2,
},
{
courseName: 'Networks',
teacher: 'Wang',
classroom: 'B202',
dayOfWeek: 3,
startTime: 5,
endTime: 6,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table.length, 12);
assert.equal(table[0].slots.length, 7);
assert.equal(table[0].section, 1);
assert.equal(table[11].section, 12);
assert.equal(table[0].period, 'morning');
assert.equal(table[4].period, 'noon');
assert.equal(table[5].period, 'afternoon');
assert.equal(table[9].period, 'evening');
assert.equal(table[0].slots[0]?.course?.courseName, 'Advanced Java');
assert.equal(table[1].slots[0]?.type, 'covered');
assert.equal(table[2].slots[0]?.type, 'empty');
assert.equal(table[4].slots[2]?.course?.courseName, 'Networks');
assert.equal(table[5].slots[2]?.type, 'covered');
assert.equal(table[8].slots[4]?.type, 'empty');
assert.equal(table[8].slots[6]?.type, 'empty');
});
test('buildScheduleTable clamps invalid section ranges safely', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Night Studio',
teacher: 'Xu',
classroom: 'C303',
dayOfWeek: 5,
startTime: 11,
endTime: 14,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table[10].slots[4]?.rowSpan, 2);
assert.equal(table[11].slots[4]?.type, 'covered');
});
test('getScheduleCellHeight returns merged visual height for rowspan cells', () => {
assert.equal(getScheduleCellHeight(1), 96);
assert.equal(getScheduleCellHeight(2), 200);
assert.equal(getScheduleCellHeight(4), 408);
});
test('getScheduleDividerOffsets returns internal section boundaries for merged cells', () => {
assert.deepEqual(getScheduleDividerOffsets(1), []);
assert.deepEqual(getScheduleDividerOffsets(2), [100]);
assert.deepEqual(getScheduleDividerOffsets(4), [100, 204, 308]);
});

View File

@@ -1,77 +0,0 @@
import type { CourseResponse } from './types';
export interface ScheduleSlot {
type: 'empty' | 'course' | 'covered';
course?: CourseResponse;
rowSpan?: number;
}
export interface ScheduleRow {
section: number;
period: 'morning' | 'noon' | 'afternoon' | 'evening';
slots: ScheduleSlot[];
}
const SECTION_COUNT = 12;
const WEEKDAY_COUNT = 7;
const SECTION_CELL_HEIGHT = 96;
const SECTION_CELL_GAP = 8;
function getPeriod(section: number): ScheduleRow['period'] {
if (section <= 4) {
return 'morning';
}
if (section === 5) {
return 'noon';
}
if (section <= 8) {
return 'afternoon';
}
return 'evening';
}
export function buildScheduleTable(schedule: CourseResponse[]) {
const rows: ScheduleRow[] = Array.from({ length: SECTION_COUNT }, (_, index) => ({
section: index + 1,
period: getPeriod(index + 1),
slots: Array.from({ length: WEEKDAY_COUNT }, () => ({ type: 'empty' as const })),
}));
for (const course of schedule) {
const dayIndex = (course.dayOfWeek ?? 0) - 1;
if (dayIndex < 0 || dayIndex >= WEEKDAY_COUNT) {
continue;
}
const startSection = Math.max(1, Math.min(SECTION_COUNT, course.startTime ?? 1));
const endSection = Math.max(startSection, Math.min(SECTION_COUNT, course.endTime ?? startSection));
const rowSpan = endSection - startSection + 1;
const startRowIndex = startSection - 1;
rows[startRowIndex].slots[dayIndex] = {
type: 'course',
course,
rowSpan,
};
for (let section = startSection + 1; section <= endSection; section += 1) {
rows[section - 1].slots[dayIndex] = {
type: 'covered',
};
}
}
return rows;
}
export function getScheduleCellHeight(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return safeRowSpan * SECTION_CELL_HEIGHT + (safeRowSpan - 1) * SECTION_CELL_GAP;
}
export function getScheduleDividerOffsets(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return Array.from({ length: safeRowSpan - 1 }, (_, index) =>
(index + 1) * SECTION_CELL_HEIGHT + index * SECTION_CELL_GAP + SECTION_CELL_GAP / 2,
);
}

View File

@@ -1,22 +0,0 @@
import { apiRequest } from './api';
import { writeCachedValue } from './cache';
import { getSchoolResultsCacheKey, writeStoredSchoolQuery } from './page-cache';
import type { LatestSchoolDataResponse } from './types';
export async function fetchLatestSchoolData() {
return apiRequest<LatestSchoolDataResponse | null>('/cqu/latest');
}
export function cacheLatestSchoolData(latest: LatestSchoolDataResponse) {
writeStoredSchoolQuery({
studentId: latest.studentId,
semester: latest.semester,
});
writeCachedValue(getSchoolResultsCacheKey(latest.studentId, latest.semester), {
queried: true,
studentId: latest.studentId,
semester: latest.semester,
schedule: latest.schedule,
grades: latest.grades,
});
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildTransferArchiveFileName,
createTransferZipArchive,
} from './transfer-archive';
test('buildTransferArchiveFileName always returns a zip filename', () => {
assert.equal(buildTransferArchiveFileName('课堂资料'), '课堂资料.zip');
assert.equal(buildTransferArchiveFileName('课堂资料.zip'), '课堂资料.zip');
});
test('createTransferZipArchive creates a zip payload that keeps nested file paths', async () => {
const archive = await createTransferZipArchive([
{
name: 'report.pdf',
relativePath: '课程资料/report.pdf',
data: new TextEncoder().encode('report'),
},
{
name: 'notes.txt',
relativePath: '课程资料/notes.txt',
data: new TextEncoder().encode('notes'),
},
]);
const bytes = new Uint8Array(await archive.arrayBuffer());
const text = new TextDecoder().decode(bytes);
assert.equal(String.fromCharCode(...bytes.slice(0, 4)), 'PK\u0003\u0004');
assert.match(text, /课程资料\/report\.pdf/);
assert.match(text, /课程资料\/notes\.txt/);
});

View File

@@ -0,0 +1,171 @@
export interface TransferArchiveEntry {
name: string;
relativePath?: string;
data: Uint8Array | ArrayBuffer | Blob;
lastModified?: number;
}
const ZIP_UTF8_FLAG = 0x0800;
const CRC32_TABLE = createCrc32Table();
function createCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value >>> 0;
}
return table;
}
function sanitizeArchivePath(entry: TransferArchiveEntry) {
const rawPath = entry.relativePath?.trim() || entry.name;
const normalizedPath = rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || entry.name;
}
function crc32(bytes: Uint8Array) {
let value = 0xFFFFFFFF;
for (const byte of bytes) {
value = CRC32_TABLE[(value ^ byte) & 0xFF] ^ (value >>> 8);
}
return (value ^ 0xFFFFFFFF) >>> 0;
}
function toDosDateTime(timestamp: number) {
const date = new Date(timestamp);
const year = Math.max(1980, date.getFullYear());
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
return {
time: (hours << 11) | (minutes << 5) | seconds,
date: ((year - 1980) << 9) | (month << 5) | day,
};
}
function writeUint16(view: DataView, offset: number, value: number) {
view.setUint16(offset, value, true);
}
function writeUint32(view: DataView, offset: number, value: number) {
view.setUint32(offset, value >>> 0, true);
}
function concatUint8Arrays(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const output = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
async function normalizeArchiveData(data: TransferArchiveEntry['data']) {
if (data instanceof Uint8Array) {
return data;
}
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}
export function buildTransferArchiveFileName(baseName: string) {
return baseName.toLowerCase().endsWith('.zip') ? baseName : `${baseName}.zip`;
}
export async function createTransferZipArchive(entries: TransferArchiveEntry[]) {
const encoder = new TextEncoder();
const fileSections: Uint8Array[] = [];
const centralDirectorySections: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const fileName = sanitizeArchivePath(entry);
const fileNameBytes = encoder.encode(fileName);
const fileData = await normalizeArchiveData(entry.data);
const checksum = crc32(fileData);
const {time, date} = toDosDateTime(entry.lastModified ?? Date.now());
const localHeader = new Uint8Array(30);
const localHeaderView = new DataView(localHeader.buffer);
writeUint32(localHeaderView, 0, 0x04034B50);
writeUint16(localHeaderView, 4, 20);
writeUint16(localHeaderView, 6, ZIP_UTF8_FLAG);
writeUint16(localHeaderView, 8, 0);
writeUint16(localHeaderView, 10, time);
writeUint16(localHeaderView, 12, date);
writeUint32(localHeaderView, 14, checksum);
writeUint32(localHeaderView, 18, fileData.byteLength);
writeUint32(localHeaderView, 22, fileData.byteLength);
writeUint16(localHeaderView, 26, fileNameBytes.byteLength);
writeUint16(localHeaderView, 28, 0);
fileSections.push(localHeader, fileNameBytes, fileData);
const centralHeader = new Uint8Array(46);
const centralHeaderView = new DataView(centralHeader.buffer);
writeUint32(centralHeaderView, 0, 0x02014B50);
writeUint16(centralHeaderView, 4, 20);
writeUint16(centralHeaderView, 6, 20);
writeUint16(centralHeaderView, 8, ZIP_UTF8_FLAG);
writeUint16(centralHeaderView, 10, 0);
writeUint16(centralHeaderView, 12, time);
writeUint16(centralHeaderView, 14, date);
writeUint32(centralHeaderView, 16, checksum);
writeUint32(centralHeaderView, 20, fileData.byteLength);
writeUint32(centralHeaderView, 24, fileData.byteLength);
writeUint16(centralHeaderView, 28, fileNameBytes.byteLength);
writeUint16(centralHeaderView, 30, 0);
writeUint16(centralHeaderView, 32, 0);
writeUint16(centralHeaderView, 34, 0);
writeUint16(centralHeaderView, 36, 0);
writeUint32(centralHeaderView, 38, 0);
writeUint32(centralHeaderView, 42, offset);
centralDirectorySections.push(centralHeader, fileNameBytes);
offset += localHeader.byteLength + fileNameBytes.byteLength + fileData.byteLength;
}
const centralDirectory = concatUint8Arrays(centralDirectorySections);
const endRecord = new Uint8Array(22);
const endRecordView = new DataView(endRecord.buffer);
writeUint32(endRecordView, 0, 0x06054B50);
writeUint16(endRecordView, 4, 0);
writeUint16(endRecordView, 6, 0);
writeUint16(endRecordView, 8, entries.length);
writeUint16(endRecordView, 10, entries.length);
writeUint32(endRecordView, 12, centralDirectory.byteLength);
writeUint32(endRecordView, 16, offset);
writeUint16(endRecordView, 20, 0);
return new Blob([
concatUint8Arrays(fileSections),
centralDirectory,
endRecord,
], {
type: 'application/zip',
});
}

View File

@@ -0,0 +1,24 @@
export type TransferRouterMode = 'browser' | 'hash';
export const APP_TRANSFER_ROUTE = '/transfer';
export const PUBLIC_TRANSFER_ROUTE = '/transfer';
export const LEGACY_PUBLIC_TRANSFER_ROUTE = '/t';
export function getTransferRouterMode(mode: string | undefined = import.meta.env?.VITE_ROUTER_MODE): TransferRouterMode {
return mode === 'hash' ? 'hash' : 'browser';
}
export function buildTransferShareUrl(
origin: string,
sessionId: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedSessionId = encodeURIComponent(sessionId);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}
return `${normalizedOrigin}${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}

View File

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createTransferFileManifest,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileManifestMessage,
createTransferFileMetaMessage,
createTransferReceiveRequestMessage,
parseTransferControlMessage,
toTransferChunk,
} from './transfer-protocol';
test('createTransferFileId uses stable file identity parts', () => {
assert.equal(
createTransferFileId({
name: 'report.pdf',
lastModified: 1730000000000,
size: 2048,
}),
'report.pdf-1730000000000-2048',
);
});
test('createTransferFileMetaMessage encodes the control payload for sender and receiver', () => {
const payload = parseTransferControlMessage(
createTransferFileMetaMessage({
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
}),
);
assert.deepEqual(payload, {
type: 'file-meta',
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
});
});
test('createTransferFileManifest keeps folder relative paths from selected files', () => {
const report = new File(['report'], 'report.pdf', {
type: 'application/pdf',
lastModified: 1730000000000,
});
Object.defineProperty(report, 'webkitRelativePath', {
configurable: true,
value: '课程资料/report.pdf',
});
const manifest = createTransferFileManifest([report]);
assert.deepEqual(manifest, [
{
id: 'report.pdf-1730000000000-6',
name: 'report.pdf',
size: 6,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]);
});
test('createTransferFileManifestMessage and createTransferReceiveRequestMessage stay parseable', () => {
const manifestPayload = parseTransferControlMessage(
createTransferFileManifestMessage([
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]),
);
assert.deepEqual(manifestPayload, {
type: 'manifest',
files: [
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
],
});
assert.deepEqual(parseTransferControlMessage(createTransferReceiveRequestMessage(['report-1'], true)), {
type: 'receive-request',
fileIds: ['report-1'],
archive: true,
});
});
test('createTransferFileCompleteMessage and createTransferCompleteMessage create parseable control messages', () => {
assert.deepEqual(parseTransferControlMessage(createTransferFileCompleteMessage('report-1')), {
type: 'file-complete',
id: 'report-1',
});
assert.deepEqual(parseTransferControlMessage(createTransferCompleteMessage()), {
type: 'transfer-complete',
});
});
test('parseTransferControlMessage returns null for invalid payloads', () => {
assert.equal(parseTransferControlMessage('{not-json'), null);
});
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
});

View File

@@ -0,0 +1,117 @@
export const TRANSFER_CHUNK_SIZE = 64 * 1024;
export const SIGNAL_POLL_INTERVAL_MS = 1000;
interface TransferFileIdentity {
name: string;
lastModified: number;
size: number;
}
export interface TransferFileDescriptor {
id: string;
name: string;
size: number;
contentType: string;
relativePath: string;
}
export type TransferControlMessage =
{
type: 'manifest';
files: TransferFileDescriptor[];
}
| {
type: 'receive-request';
fileIds: string[];
archive: boolean;
}
| ({
type: 'file-meta';
} & TransferFileDescriptor)
| {
type: 'file-complete';
id: string;
}
| {
type: 'transfer-complete';
};
export function createTransferFileId(file: TransferFileIdentity) {
return `${file.name}-${file.lastModified}-${file.size}`;
}
export function getTransferFileRelativePath(file: File) {
const rawRelativePath = ('webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath)
? file.webkitRelativePath
: file.name;
const normalizedPath = rawRelativePath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || file.name;
}
export function createTransferFileManifest(files: File[]): TransferFileDescriptor[] {
return files.map((file) => ({
id: createTransferFileId(file),
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
relativePath: getTransferFileRelativePath(file),
}));
}
export function createTransferFileManifestMessage(files: TransferFileDescriptor[]) {
return JSON.stringify({
type: 'manifest',
files,
} satisfies TransferControlMessage);
}
export function createTransferReceiveRequestMessage(fileIds: string[], archive: boolean) {
return JSON.stringify({
type: 'receive-request',
fileIds,
archive,
} satisfies TransferControlMessage);
}
export function createTransferFileMetaMessage(payload: TransferFileDescriptor) {
return JSON.stringify({
type: 'file-meta',
...payload,
} satisfies TransferControlMessage);
}
export function createTransferFileCompleteMessage(id: string) {
return JSON.stringify({
type: 'file-complete',
id,
} satisfies TransferControlMessage);
}
export function createTransferCompleteMessage() {
return JSON.stringify({
type: 'transfer-complete',
} satisfies TransferControlMessage);
}
export function parseTransferControlMessage(payload: string): TransferControlMessage | null {
try {
return JSON.parse(payload) as TransferControlMessage;
} catch {
return null;
}
}
export async function toTransferChunk(data: ArrayBuffer | Blob) {
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}

View File

@@ -0,0 +1,19 @@
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
export async function waitForTransferChannelDrain(
channel: RTCDataChannel,
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
) {
if (channel.bufferedAmount <= maxBufferedAmount) {
return;
}
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
window.clearInterval(timer);
resolve();
}
}, 40);
});
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushPendingRemoteIceCandidates,
handleRemoteIceCandidate,
} from './transfer-signaling';
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: null,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const candidate: RTCIceCandidateInit = {
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
};
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
assert.deepEqual(appliedCandidates, []);
assert.deepEqual(pendingCandidates, [candidate]);
});
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: { type: 'answer' } as RTCSessionDescription,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const pendingCandidates: RTCIceCandidateInit[] = [
{
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
{
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
];
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
assert.deepEqual(appliedCandidates, pendingCandidates);
assert.deepEqual(remainingCandidates, []);
});

View File

@@ -0,0 +1,32 @@
interface RemoteIceCapableConnection {
remoteDescription: RTCSessionDescription | null;
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
}
export async function handleRemoteIceCandidate(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
candidate: RTCIceCandidateInit,
) {
if (!connection.remoteDescription) {
return [...pendingCandidates, candidate];
}
await connection.addIceCandidate(candidate);
return pendingCandidates;
}
export async function flushPendingRemoteIceCandidates(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
) {
if (!connection.remoteDescription || pendingCandidates.length === 0) {
return pendingCandidates;
}
for (const candidate of pendingCandidates) {
await connection.addIceCandidate(candidate);
}
return [];
}

56
front/src/lib/transfer.ts Normal file
View File

@@ -0,0 +1,56 @@
import { apiRequest } from './api';
import type {
LookupTransferSessionResponse,
PollTransferSignalsResponse,
TransferSessionResponse,
} from './types';
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
{urls: 'stun:stun.cloudflare.com:3478'},
{urls: 'stun:stun.l.google.com:19302'},
];
export function toTransferFilePayload(files: File[]) {
return files.map((file) => ({
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
}));
}
export function createTransferSession(files: File[]) {
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
method: 'POST',
body: {
files: toTransferFilePayload(files),
},
});
}
export function lookupTransferSession(pickupCode: string) {
return apiRequest<LookupTransferSessionResponse>(
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(pickupCode)}`,
);
}
export function joinTransferSession(sessionId: string) {
return apiRequest<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
method: 'POST',
});
}
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
method: 'POST',
body: {
type,
payload,
},
});
}
export function pollTransferSignals(sessionId: string, role: 'sender' | 'receiver', after: number) {
return apiRequest<PollTransferSignalsResponse>(
`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}&after=${after}`,
);
}

View File

@@ -16,7 +16,6 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminSummary { export interface AdminSummary {
totalUsers: number; totalUsers: number;
totalFiles: number; totalFiles: number;
usersWithSchoolCache: number;
} }
export interface AdminUser { export interface AdminUser {
@@ -25,8 +24,6 @@ export interface AdminUser {
email: string; email: string;
phoneNumber: string | null; phoneNumber: string | null;
createdAt: string; createdAt: string;
lastSchoolStudentId: string | null;
lastSchoolSemester: string | null;
role: AdminUserRole; role: AdminUserRole;
banned: boolean; banned: boolean;
} }
@@ -44,17 +41,6 @@ export interface AdminFile {
ownerEmail: string; ownerEmail: string;
} }
export interface AdminSchoolSnapshot {
id: number;
userId: number;
username: string;
email: string;
studentId: string | null;
semester: string | null;
scheduleCount: number;
gradeCount: number;
}
export interface AdminPasswordResetResponse { export interface AdminPasswordResetResponse {
temporaryPassword: string; temporaryPassword: string;
} }
@@ -101,24 +87,50 @@ export interface DownloadUrlResponse {
url: string; url: string;
} }
export interface CourseResponse { export interface CreateFileShareLinkResponse {
courseName: string; token: string;
teacher: string | null; filename: string;
classroom: string | null; size: number;
dayOfWeek: number | null; contentType: string | null;
startTime: number | null; createdAt: string;
endTime: number | null;
} }
export interface GradeResponse { export interface FileShareDetailsResponse {
courseName: string; token: string;
grade: number | null; ownerUsername: string;
semester: string | null; filename: string;
size: number;
contentType: string | null;
directory: boolean;
createdAt: string;
} }
export interface LatestSchoolDataResponse { export interface TransferFileItem {
studentId: string; name: string;
semester: string; size: number;
schedule: CourseResponse[]; contentType: string;
grades: GradeResponse[]; }
export interface TransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
files: TransferFileItem[];
}
export interface LookupTransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
}
export interface TransferSignalEnvelope {
cursor: number;
type: string;
payload: string;
}
export interface PollTransferSignalsResponse {
items: TransferSignalEnvelope[];
nextCursor: number;
} }

View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle2, DownloadCloud, Link2, Loader2, LogIn, Save } from 'lucide-react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types';
function formatFileSize(size: number) {
if (size <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** unitIndex;
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export default function FileShare() {
const { token } = useParams();
const location = useLocation();
const navigate = useNavigate();
const { session } = useAuth();
const [details, setDetails] = useState<FileShareDetailsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [path, setPath] = useState('/下载');
const [importing, setImporting] = useState(false);
const [importedFile, setImportedFile] = useState<FileMetadata | null>(null);
const [pathPickerOpen, setPathPickerOpen] = useState(false);
useEffect(() => {
if (!token) {
setLoading(false);
setError('分享链接无效');
return;
}
let active = true;
setLoading(true);
setError('');
setImportedFile(null);
void getFileShareDetails(token)
.then((response) => {
if (!active) {
return;
}
setDetails(response);
})
.catch((requestError) => {
if (!active) {
return;
}
setError(requestError instanceof Error ? requestError.message : '无法读取分享详情');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token]);
async function handleImportToPath(nextPath: string) {
setPath(normalizeNetdiskTargetPath(nextPath));
await handleImportAtPath(nextPath);
}
async function handleImportAtPath(nextPath: string) {
if (!token) {
return;
}
setImporting(true);
setError('');
try {
const normalizedPath = normalizeNetdiskTargetPath(nextPath);
const savedFile = await importSharedFile(token, normalizedPath);
setPath(normalizedPath);
setImportedFile(savedFile);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '导入共享文件失败');
throw requestError;
} finally {
setImporting(false);
}
}
return (
<div className="min-h-screen bg-[#07101D] px-4 py-10 text-white">
<div className="mx-auto w-full max-w-3xl">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 via-sky-500 to-blue-500 shadow-lg shadow-cyan-500/20">
<Link2 className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-3 text-slate-400"></p>
</div>
<div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl">
{loading ? (
<div className="flex items-center justify-center gap-3 py-20 text-slate-300">
<Loader2 className="h-5 w-5 animate-spin" />
...
</div>
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-5 py-4 text-sm text-rose-200">
{error}
</div>
) : details ? (
<div className="space-y-6">
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-cyan-500/10">
<DownloadCloud className="h-6 w-6 text-cyan-300" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-semibold text-white">{details.filename}</h2>
<p className="mt-2 text-sm text-slate-400">
<span className="text-slate-200">{details.ownerUsername}</span> · {formatFileSize(details.size)}
</p>
<p className="mt-1 text-xs text-slate-500">
{new Date(details.createdAt).toLocaleString('zh-CN')}
</p>
</div>
</div>
</div>
{!session?.token ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 p-6">
<p className="text-sm text-amber-100"></p>
<Button
className="mt-4 bg-[#336EFF] hover:bg-blue-600 text-white"
onClick={() => navigate(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)}
>
<LogIn className="mr-2 h-4 w-4" />
</Button>
</div>
) : (
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4">
<p className="text-sm font-medium text-slate-200"></p>
<p className="mt-2 text-sm text-emerald-300">{path}</p>
<p className="mt-1 text-xs text-slate-500"></p>
</div>
{importedFile ? (
<div className="mt-5 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-4 text-sm text-emerald-100">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-300" />
{importedFile.path}/{importedFile.filename}
</div>
<Button
variant="outline"
className="mt-4 border-white/10 text-slate-100 hover:bg-white/10"
onClick={() => navigate('/files')}
>
</Button>
</div>
) : (
<Button
className="mt-5 bg-emerald-500 hover:bg-emerald-600 text-white"
disabled={importing}
onClick={() => setPathPickerOpen(true)}
>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
)}
</div>
)}
</div>
) : null}
</div>
</div>
<NetdiskPathPickerModal
isOpen={pathPickerOpen}
title="选择导入位置"
description="选择这个分享文件要导入到你网盘中的哪个目录。"
initialPath={path}
confirmLabel="导入到这里"
onClose={() => setPathPickerOpen(false)}
onConfirm={handleImportToPath}
/>
</div>
);
}

View File

@@ -18,17 +18,23 @@ import {
LayoutGrid, LayoutGrid,
List, List,
MoreVertical, MoreVertical,
Copy,
Share2,
TriangleAlert, TriangleAlert,
X, X,
Edit2, Edit2,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button'; import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Input } from '@/src/components/ui/input'; import { Input } from '@/src/components/ui/input';
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api'; import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types'; import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
@@ -122,6 +128,7 @@ function toUiFile(file: FileMetadata) {
} }
type UiFile = ReturnType<typeof toUiFile>; type UiFile = ReturnType<typeof toUiFile>;
type NetdiskTargetAction = 'move' | 'copy';
export default function Files() { export default function Files() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? []; const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
@@ -139,11 +146,14 @@ export default function Files() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null); const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null); const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(null);
const [newFileName, setNewFileName] = useState(''); const [newFileName, setNewFileName] = useState('');
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [renameError, setRenameError] = useState(''); const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
const loadCurrentPath = async (pathParts: string[]) => { const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>( const response = await apiRequest<PageResponse<FileMetadata>>(
@@ -210,6 +220,12 @@ export default function Files() {
setDeleteModalOpen(true); setDeleteModalOpen(true);
}; };
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
setActiveDropdown(null);
};
const handleUploadClick = () => { const handleUploadClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -478,6 +494,23 @@ export default function Files() {
await loadCurrentPath(currentPath).catch(() => undefined); await loadCurrentPath(currentPath).catch(() => undefined);
}; };
const handleMoveToPath = async (path: string) => {
if (!targetActionFile || !targetAction) {
return;
}
if (targetAction === 'move') {
await moveFileToNetdiskPath(targetActionFile.id, path);
setSelectedFile((previous) => clearSelectionIfDeleted(previous, targetActionFile.id));
} else {
await copyFileToNetdiskPath(targetActionFile.id, path);
}
setTargetAction(null);
setTargetActionFile(null);
await loadCurrentPath(currentPath).catch(() => undefined);
};
const handleDownload = async (targetFile: UiFile | null = selectedFile) => { const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
if (!targetFile) { if (!targetFile) {
return; return;
@@ -526,6 +559,21 @@ export default function Files() {
setUploads([]); setUploads([]);
}; };
const handleShare = async (targetFile: UiFile) => {
try {
const response = await createFileShareLink(targetFile.id);
const shareUrl = getCurrentFileShareUrl(response.token);
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus('分享链接已复制到剪贴板');
} catch {
setShareStatus(`分享链接:${shareUrl}`);
}
} catch (error) {
setShareStatus(error instanceof Error ? error.message : '创建分享链接失败');
}
};
return ( return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]"> <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */} {/* Left Sidebar */}
@@ -591,6 +639,9 @@ export default function Files() {
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
{shareStatus ? (
<div className="hidden max-w-xs truncate text-xs text-emerald-300 md:block">{shareStatus}</div>
) : null}
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg"> <div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
@@ -665,6 +716,9 @@ export default function Files() {
activeDropdown={activeDropdown} activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))} onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload} onDownload={handleDownload}
onShare={handleShare}
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
onRename={openRenameModal} onRename={openRenameModal}
onDelete={openDeleteModal} onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)} onClose={() => setActiveDropdown(null)}
@@ -694,6 +748,9 @@ export default function Files() {
activeDropdown={activeDropdown} activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))} onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload} onDownload={handleDownload}
onShare={handleShare}
onMove={(file) => openTargetActionModal(file, 'move')}
onCopy={(file) => openTargetActionModal(file, 'copy')}
onRename={openRenameModal} onRename={openRenameModal}
onDelete={openDeleteModal} onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)} onClose={() => setActiveDropdown(null)}
@@ -772,9 +829,20 @@ export default function Files() {
<div className="pt-4 space-y-3 border-t border-white/10"> <div className="pt-4 space-y-3 border-t border-white/10">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{selectedFile.type !== 'folder' ? (
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => void handleShare(selectedFile)}>
<Share2 className="w-4 h-4" />
</Button>
) : null}
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openRenameModal(selectedFile)}> <Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openRenameModal(selectedFile)}>
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</Button> </Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'move')}>
<Folder className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'copy')}>
<Copy className="w-4 h-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300" className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
@@ -798,6 +866,11 @@ export default function Files() {
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
)} )}
{shareStatus && selectedFile.type !== 'folder' ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{shareStatus}
</div>
) : null}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -1024,6 +1097,23 @@ export default function Files() {
</div> </div>
)} )}
</AnimatePresence> </AnimatePresence>
<NetdiskPathPickerModal
isOpen={Boolean(targetActionFile && targetAction)}
title={targetAction === 'copy' ? '选择复制目标' : '选择移动目标'}
description={
targetAction === 'copy'
? '选择要把当前文件或文件夹复制到哪个目录。'
: '选择要把当前文件或文件夹移动到哪个目录。'
}
initialPath={toBackendPath(currentPath)}
confirmLabel={targetAction === 'copy' ? '复制到这里' : '移动到这里'}
onClose={() => {
setTargetAction(null);
setTargetActionFile(null);
}}
onConfirm={handleMoveToPath}
/>
</div> </div>
); );
} }
@@ -1042,6 +1132,9 @@ function FileActionMenu({
activeDropdown, activeDropdown,
onToggle, onToggle,
onDownload, onDownload,
onShare,
onMove,
onCopy,
onRename, onRename,
onDelete, onDelete,
onClose, onClose,
@@ -1050,6 +1143,9 @@ function FileActionMenu({
activeDropdown: number | null; activeDropdown: number | null;
onToggle: (fileId: number) => void; onToggle: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>; onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void; onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void; onDelete: (file: UiFile) => void;
onClose: () => void; onClose: () => void;
@@ -1093,6 +1189,38 @@ function FileActionMenu({
> >
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'} <Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
</button> </button>
{file.type !== 'folder' ? (
<button
onClick={(event) => {
event.stopPropagation();
void onShare(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Share2 className="w-4 h-4" />
</button>
) : null}
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button <button
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react'; import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react';
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src
import { Button } from '@/src/components/ui/button'; import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input'; import { Input } from '@/src/components/ui/input';
import { apiRequest, ApiError } from '@/src/lib/api'; import { apiRequest, ApiError } from '@/src/lib/api';
import { getPostLoginRedirectPath } from '@/src/lib/file-share';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session'; import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse } from '@/src/lib/types'; import type { AuthResponse } from '@/src/lib/types';
@@ -15,6 +16,7 @@ const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -63,7 +65,7 @@ export default function Login() {
saveStoredSession(createSession(auth)); saveStoredSession(createSession(auth));
markPostLoginPending(); markPostLoginPending();
setLoading(false); setLoading(false);
navigate('/overview'); navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) { } catch (requestError) {
setLoading(false); setLoading(false);
setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试'); setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试');
@@ -89,7 +91,7 @@ export default function Login() {
saveStoredSession(createSession(auth)); saveStoredSession(createSession(auth));
markPostLoginPending(); markPostLoginPending();
setLoading(false); setLoading(false);
navigate('/overview'); navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) { } catch (requestError) {
setLoading(false); setLoading(false);
setError(requestError instanceof Error ? requestError.message : '注册失败,请稍后重试'); setError(requestError instanceof Error ? requestError.message : '注册失败,请稍后重试');
@@ -127,7 +129,7 @@ export default function Login() {
</div> </div>
<p className="text-lg text-slate-400 leading-relaxed"> <p className="text-lg text-slate-400 leading-relaxed">
YOYUZH YOYUZH 使
</p> </p>
</motion.div> </motion.div>
)} )}

Some files were not shown because too many files have changed in this diff Show More