From 43358e29d7c1807921488e0c0117fc0c36ffd4e3 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Fri, 20 Mar 2026 14:16:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=BF=AB=E4=BC=A0=EF=BC=8C?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=BF=AB=E4=BC=A0=E5=92=8C=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E4=BA=92=E4=BC=A0=E7=AD=89=E4=B8=80=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 53 +- backend/sql/mysql-init.sql | 32 - backend/sql/opengauss-init.sql | 32 +- .../com/yoyuzh/PortalBackendApplication.java | 4 +- .../com/yoyuzh/admin/AdminController.java | 7 - .../admin/AdminSchoolSnapshotResponse.java | 13 - .../java/com/yoyuzh/admin/AdminService.java | 41 +- .../yoyuzh/admin/AdminSummaryResponse.java | 3 +- .../com/yoyuzh/admin/AdminUserResponse.java | 2 - .../src/main/java/com/yoyuzh/auth/User.java | 22 - .../java/com/yoyuzh/auth/UserRepository.java | 4 - .../com/yoyuzh/config/CquApiProperties.java | 35 - .../com/yoyuzh/config/SecurityConfig.java | 7 +- .../src/main/java/com/yoyuzh/cqu/Course.java | 154 --- .../java/com/yoyuzh/cqu/CourseRepository.java | 16 - .../java/com/yoyuzh/cqu/CourseResponse.java | 11 - .../java/com/yoyuzh/cqu/CquApiClient.java | 40 - .../java/com/yoyuzh/cqu/CquController.java | 52 -- .../java/com/yoyuzh/cqu/CquDataService.java | 242 ----- .../com/yoyuzh/cqu/CquMockDataFactory.java | 109 --- .../src/main/java/com/yoyuzh/cqu/Grade.java | 110 --- .../java/com/yoyuzh/cqu/GradeRepository.java | 18 - .../java/com/yoyuzh/cqu/GradeResponse.java | 8 - .../yoyuzh/cqu/LatestSchoolDataResponse.java | 11 - .../com/yoyuzh/files/CopyFileRequest.java | 9 + .../files/CreateFileShareLinkResponse.java | 12 + .../java/com/yoyuzh/files/FileController.java | 47 + .../java/com/yoyuzh/files/FileService.java | 223 +++++ .../files/FileShareDetailsResponse.java | 14 + .../java/com/yoyuzh/files/FileShareLink.java | 89 ++ .../yoyuzh/files/FileShareLinkRepository.java | 12 + .../yoyuzh/files/ImportSharedFileRequest.java | 6 + .../com/yoyuzh/files/MoveFileRequest.java | 9 + .../yoyuzh/files/StoredFileRepository.java | 9 + .../CreateTransferSessionRequest.java | 12 + .../LookupTransferSessionResponse.java | 10 + .../transfer/PollTransferSignalsResponse.java | 9 + .../yoyuzh/transfer/TransferController.java | 71 ++ .../com/yoyuzh/transfer/TransferFileItem.java | 13 + .../com/yoyuzh/transfer/TransferRole.java | 21 + .../com/yoyuzh/transfer/TransferService.java | 93 ++ .../com/yoyuzh/transfer/TransferSession.java | 72 ++ .../transfer/TransferSessionResponse.java | 12 + .../yoyuzh/transfer/TransferSessionStore.java | 55 ++ .../transfer/TransferSignalEnvelope.java | 8 + .../transfer/TransferSignalRequest.java | 11 + .../src/main/resources/application-dev.yml | 2 - backend/src/main/resources/application.yml | 4 - .../admin/AdminControllerIntegrationTest.java | 10 +- .../RefreshTokenServiceIntegrationTest.java | 4 +- .../com/yoyuzh/config/SecurityConfigTest.java | 1 + .../com/yoyuzh/cqu/CquDataServiceTest.java | 202 ---- .../cqu/CquDataServiceTransactionTest.java | 83 -- .../yoyuzh/cqu/CquMockDataFactoryTest.java | 42 - .../com/yoyuzh/files/FileServiceTest.java | 194 +++- .../FileShareControllerIntegrationTest.java | 228 +++++ .../TransferControllerIntegrationTest.java | 123 +++ .../yoyuzh/transfer/TransferSessionTest.java | 54 ++ ...2026-03-20-file-share-and-transfer-save.md | 88 ++ ...2026-03-20-netdisk-path-picker-and-move.md | 84 ++ .../2026-03-20-transfer-module-refactor.md | 137 +++ .../plans/2026-03-20-transfer-webrtc-share.md | 87 ++ front/metadata.json | 2 +- front/src/App.tsx | 34 +- front/src/admin/AdminApp.tsx | 9 - front/src/admin/dashboard.tsx | 9 +- front/src/admin/data-provider.test.ts | 25 +- front/src/admin/data-provider.ts | 14 +- front/src/admin/school-snapshots-list.tsx | 22 - front/src/admin/users-list.tsx | 2 - front/src/auth/admin-access.test.ts | 1 - front/src/components/layout/Layout.test.ts | 8 + front/src/components/layout/Layout.tsx | 18 +- .../components/ui/NetdiskPathPickerModal.tsx | 234 +++++ front/src/lib/cache.test.ts | 14 +- front/src/lib/file-copy.ts | 12 + front/src/lib/file-move.ts | 12 + front/src/lib/file-share.test.ts | 32 + front/src/lib/file-share.ts | 49 + front/src/lib/netdisk-paths.test.ts | 30 + front/src/lib/netdisk-paths.ts | 31 + front/src/lib/netdisk-upload.test.ts | 25 + front/src/lib/netdisk-upload.ts | 60 ++ front/src/lib/page-cache.ts | 33 +- front/src/lib/schedule-table.test.ts | 74 -- front/src/lib/schedule-table.ts | 77 -- front/src/lib/school.ts | 22 - front/src/lib/transfer-archive.test.ts | 34 + front/src/lib/transfer-archive.ts | 171 ++++ front/src/lib/transfer-links.ts | 24 + front/src/lib/transfer-protocol.test.ts | 122 +++ front/src/lib/transfer-protocol.ts | 117 +++ front/src/lib/transfer-runtime.ts | 19 + front/src/lib/transfer-signaling.test.ts | 54 ++ front/src/lib/transfer-signaling.ts | 32 + front/src/lib/transfer.ts | 56 ++ front/src/lib/types.ts | 72 +- front/src/pages/FileShare.tsx | 209 +++++ front/src/pages/Files.tsx | 128 +++ front/src/pages/Login.tsx | 10 +- front/src/pages/Overview.tsx | 304 +++--- front/src/pages/School.tsx | 596 ------------ front/src/pages/Transfer.tsx | 691 ++++++++++++++ front/src/pages/TransferReceive.tsx | 879 ++++++++++++++++++ front/src/pages/transfer-state.test.ts | 84 ++ front/src/pages/transfer-state.ts | 52 ++ scripts/local-smoke.ps1 | 6 - scripts/oss-deploy-lib.mjs | 4 +- scripts/oss-deploy-lib.test.mjs | 3 +- 109 files changed, 5237 insertions(+), 2465 deletions(-) delete mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java delete mode 100644 backend/src/main/java/com/yoyuzh/config/CquApiProperties.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/Course.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CquController.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CquDataService.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/Grade.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java delete mode 100644 backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/files/CopyFileRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/files/CreateFileShareLinkResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/files/FileShareDetailsResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/files/FileShareLink.java create mode 100644 backend/src/main/java/com/yoyuzh/files/FileShareLinkRepository.java create mode 100644 backend/src/main/java/com/yoyuzh/files/ImportSharedFileRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/files/MoveFileRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/PollTransferSignalsResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferController.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferRole.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferService.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSession.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSignalEnvelope.java create mode 100644 backend/src/main/java/com/yoyuzh/transfer/TransferSignalRequest.java delete mode 100644 backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java delete mode 100644 backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java delete mode 100644 backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java create mode 100644 docs/superpowers/plans/2026-03-20-file-share-and-transfer-save.md create mode 100644 docs/superpowers/plans/2026-03-20-netdisk-path-picker-and-move.md create mode 100644 docs/superpowers/plans/2026-03-20-transfer-module-refactor.md create mode 100644 docs/superpowers/plans/2026-03-20-transfer-webrtc-share.md delete mode 100644 front/src/admin/school-snapshots-list.tsx create mode 100644 front/src/components/ui/NetdiskPathPickerModal.tsx create mode 100644 front/src/lib/file-copy.ts create mode 100644 front/src/lib/file-move.ts create mode 100644 front/src/lib/file-share.test.ts create mode 100644 front/src/lib/file-share.ts create mode 100644 front/src/lib/netdisk-paths.test.ts create mode 100644 front/src/lib/netdisk-paths.ts create mode 100644 front/src/lib/netdisk-upload.test.ts create mode 100644 front/src/lib/netdisk-upload.ts delete mode 100644 front/src/lib/schedule-table.test.ts delete mode 100644 front/src/lib/schedule-table.ts delete mode 100644 front/src/lib/school.ts create mode 100644 front/src/lib/transfer-archive.test.ts create mode 100644 front/src/lib/transfer-archive.ts create mode 100644 front/src/lib/transfer-links.ts create mode 100644 front/src/lib/transfer-protocol.test.ts create mode 100644 front/src/lib/transfer-protocol.ts create mode 100644 front/src/lib/transfer-runtime.ts create mode 100644 front/src/lib/transfer-signaling.test.ts create mode 100644 front/src/lib/transfer-signaling.ts create mode 100644 front/src/lib/transfer.ts create mode 100644 front/src/pages/FileShare.tsx delete mode 100644 front/src/pages/School.tsx create mode 100644 front/src/pages/Transfer.tsx create mode 100644 front/src/pages/TransferReceive.tsx create mode 100644 front/src/pages/transfer-state.test.ts create mode 100644 front/src/pages/transfer-state.ts diff --git a/backend/README.md b/backend/README.md index 1cabee8..fc636be 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,7 +4,7 @@ - 用户注册、登录、JWT 鉴权、用户信息接口 - 个人网盘上传、下载、删除、目录管理、分页列表 -- CQU 课表与成绩聚合接口 +- 快传会话与浏览器间 P2P 信令接口 - Swagger 文档、统一异常、日志输出 ## 环境要求 @@ -32,7 +32,6 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev `dev` 环境特点: - 数据库使用 H2 文件库 -- CQU 接口返回 mock 数据 - 方便和 `vue/` 前端直接联调 JWT 启动要求: @@ -54,39 +53,20 @@ JWT 启动要求: ## 旧库升级 -如果服务器数据库是按旧版脚本初始化的,需要先补齐下面这些字段,否则登录后的首页接口可能在查询用户、课表或成绩时直接报 500。 +如果服务器数据库是按旧版脚本初始化的,旧教务相关字段和表可以保留但不会再被当前代码使用。新环境请直接使用最新初始化脚本,不再创建教务缓存表。 MySQL: ```sql -ALTER TABLE portal_user - ADD COLUMN last_school_student_id VARCHAR(64) NULL, - ADD COLUMN last_school_semester VARCHAR(64) NULL; - -ALTER TABLE portal_course - ADD COLUMN semester VARCHAR(64) NULL, - ADD COLUMN student_id VARCHAR(64) NULL; - -ALTER TABLE portal_grade - ADD COLUMN student_id VARCHAR(64) NULL; - -CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id); -CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id); +DROP TABLE IF EXISTS portal_course; +DROP TABLE IF EXISTS portal_grade; ``` openGauss: ```sql -ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_student_id VARCHAR(64); -ALTER TABLE portal_user ADD COLUMN IF NOT EXISTS last_school_semester VARCHAR(64); - -ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS semester VARCHAR(64); -ALTER TABLE portal_course ADD COLUMN IF NOT EXISTS student_id VARCHAR(64); - -ALTER TABLE portal_grade ADD COLUMN IF NOT EXISTS student_id VARCHAR(64); - -CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id); -CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id); +DROP TABLE IF EXISTS portal_course; +DROP TABLE IF EXISTS portal_grade; ``` ## 主要接口 @@ -103,22 +83,11 @@ CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, sem - `GET /api/files/download/{fileId}` - `GET /api/files/download/{fileId}/url` - `DELETE /api/files/{fileId}` -- `GET /api/cqu/schedule` -- `GET /api/cqu/grades` - -## CQU 配置 - -部署到真实环境时修改: - -```yaml -app: - cqu: - base-url: https://your-cqu-api - require-login: false - mock-enabled: false -``` - -当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。 +- `POST /api/transfer/sessions` +- `GET /api/transfer/sessions/lookup` +- `POST /api/transfer/sessions/{sessionId}/join` +- `POST /api/transfer/sessions/{sessionId}/signals` +- `GET /api/transfer/sessions/{sessionId}/signals` ## OSS 直传说明 diff --git a/backend/sql/mysql-init.sql b/backend/sql/mysql-init.sql index 97a34ec..6a09ecc 100644 --- a/backend/sql/mysql-init.sql +++ b/backend/sql/mysql-init.sql @@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS portal_user ( email VARCHAR(128) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_school_student_id VARCHAR(64), - last_school_semester VARCHAR(64), CONSTRAINT uk_portal_user_username UNIQUE (username), CONSTRAINT uk_portal_user_email UNIQUE (email) ); @@ -27,35 +25,5 @@ CREATE TABLE IF NOT EXISTS portal_file ( CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename) ); -CREATE TABLE IF NOT EXISTS portal_course ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - user_id BIGINT NOT NULL, - course_name VARCHAR(255) NOT NULL, - semester VARCHAR(64), - student_id VARCHAR(64), - teacher VARCHAR(255), - classroom VARCHAR(255), - day_of_week INT, - start_time INT, - end_time INT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_portal_course_user FOREIGN KEY (user_id) REFERENCES portal_user (id) -); - -CREATE TABLE IF NOT EXISTS portal_grade ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - user_id BIGINT NOT NULL, - course_name VARCHAR(255) NOT NULL, - grade DOUBLE NOT NULL, - semester VARCHAR(64) NOT NULL, - student_id VARCHAR(64), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_portal_grade_user FOREIGN KEY (user_id) REFERENCES portal_user (id) -); - CREATE INDEX idx_user_created_at ON portal_user (created_at); CREATE INDEX idx_file_created_at ON portal_file (created_at); -CREATE INDEX idx_course_user_semester ON portal_course (user_id, semester, student_id); -CREATE INDEX idx_course_user_created ON portal_course (user_id, created_at); -CREATE INDEX idx_grade_user_semester ON portal_grade (user_id, semester, student_id); -CREATE INDEX idx_grade_user_created ON portal_grade (user_id, created_at); diff --git a/backend/sql/opengauss-init.sql b/backend/sql/opengauss-init.sql index db3414e..4ff2539 100644 --- a/backend/sql/opengauss-init.sql +++ b/backend/sql/opengauss-init.sql @@ -3,9 +3,7 @@ CREATE TABLE IF NOT EXISTS portal_user ( username VARCHAR(64) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_school_student_id VARCHAR(64), - last_school_semester VARCHAR(64) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS portal_file ( @@ -21,33 +19,5 @@ CREATE TABLE IF NOT EXISTS portal_file ( CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename) ); -CREATE TABLE IF NOT EXISTS portal_course ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES portal_user (id), - course_name VARCHAR(255) NOT NULL, - semester VARCHAR(64), - student_id VARCHAR(64), - teacher VARCHAR(255), - classroom VARCHAR(255), - day_of_week INTEGER, - start_time INTEGER, - end_time INTEGER, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS portal_grade ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES portal_user (id), - course_name VARCHAR(255) NOT NULL, - grade DOUBLE PRECISION NOT NULL, - semester VARCHAR(64) NOT NULL, - student_id VARCHAR(64), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at); CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at); -CREATE INDEX IF NOT EXISTS idx_course_user_semester ON portal_course (user_id, semester, student_id); -CREATE INDEX IF NOT EXISTS idx_course_user_created ON portal_course (user_id, created_at); -CREATE INDEX IF NOT EXISTS idx_grade_user_semester ON portal_grade (user_id, semester, student_id); -CREATE INDEX IF NOT EXISTS idx_grade_user_created ON portal_grade (user_id, created_at); diff --git a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java index d634c15..6230fb8 100644 --- a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java +++ b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java @@ -1,10 +1,9 @@ package com.yoyuzh; -import com.yoyuzh.config.CquApiProperties; +import com.yoyuzh.config.AdminProperties; import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.JwtProperties; -import com.yoyuzh.config.AdminProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -13,7 +12,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties @EnableConfigurationProperties({ JwtProperties.class, FileStorageProperties.class, - CquApiProperties.class, CorsProperties.class, AdminProperties.class }) diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java index 9b4870d..526e3fc 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminController.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -50,13 +50,6 @@ public class AdminController { return ApiResponse.success(); } - @GetMapping("/school-snapshots") - public ApiResponse> schoolSnapshots( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - return ApiResponse.success(adminService.listSchoolSnapshots(page, size)); - } - @PatchMapping("/users/{userId}/role") public ApiResponse updateUserRole(@PathVariable Long userId, @Valid @RequestBody AdminUserRoleUpdateRequest request) { diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java deleted file mode 100644 index bc90717..0000000 --- a/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java +++ /dev/null @@ -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 -) { -} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index 98a04c9..876fba3 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -8,8 +8,6 @@ import com.yoyuzh.auth.RefreshTokenService; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.PageResponse; -import com.yoyuzh.cqu.CourseRepository; -import com.yoyuzh.cqu.GradeRepository; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; @@ -31,8 +29,6 @@ public class AdminService { private final UserRepository userRepository; private final StoredFileRepository storedFileRepository; private final FileService fileService; - private final CourseRepository courseRepository; - private final GradeRepository gradeRepository; private final PasswordEncoder passwordEncoder; private final RefreshTokenService refreshTokenService; private final SecureRandom secureRandom = new SecureRandom(); @@ -40,8 +36,7 @@ public class AdminService { public AdminSummaryResponse getSummary() { return new AdminSummaryResponse( userRepository.count(), - storedFileRepository.count(), - userRepository.countByLastSchoolStudentIdIsNotNull() + storedFileRepository.count() ); } @@ -69,16 +64,6 @@ public class AdminService { return new PageResponse<>(items, result.getTotalElements(), page, size); } - public PageResponse listSchoolSnapshots(int page, int size) { - Page result = userRepository.findByLastSchoolStudentIdIsNotNull( - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) - ); - List items = result.getContent().stream() - .map(this::toSchoolSnapshotResponse) - .toList(); - return new PageResponse<>(items, result.getTotalElements(), page, size); - } - @Transactional public void deleteFile(Long fileId) { StoredFile storedFile = storedFileRepository.findById(fileId) @@ -126,8 +111,6 @@ public class AdminService { user.getEmail(), user.getPhoneNumber(), user.getCreatedAt(), - user.getLastSchoolStudentId(), - user.getLastSchoolSemester(), user.getRole(), user.isBanned() ); @@ -149,28 +132,6 @@ public class AdminService { ); } - private AdminSchoolSnapshotResponse toSchoolSnapshotResponse(User user) { - String studentId = user.getLastSchoolStudentId(); - String semester = user.getLastSchoolSemester(); - long scheduleCount = studentId == null || semester == null - ? 0 - : courseRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester); - long gradeCount = studentId == null || semester == null - ? 0 - : gradeRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester); - - return new AdminSchoolSnapshotResponse( - user.getId(), - user.getId(), - user.getUsername(), - user.getEmail(), - studentId, - semester, - scheduleCount, - gradeCount - ); - } - private User getRequiredUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在")); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java index 7df26a7..9aada78 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java @@ -2,7 +2,6 @@ package com.yoyuzh.admin; public record AdminSummaryResponse( long totalUsers, - long totalFiles, - long usersWithSchoolCache + long totalFiles ) { } diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java index 4cdfd7c..642e4f9 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java @@ -10,8 +10,6 @@ public record AdminUserResponse( String email, String phoneNumber, LocalDateTime createdAt, - String lastSchoolStudentId, - String lastSchoolSemester, UserRole role, boolean banned ) { diff --git a/backend/src/main/java/com/yoyuzh/auth/User.java b/backend/src/main/java/com/yoyuzh/auth/User.java index 6dcdf3d..fd5c6ab 100644 --- a/backend/src/main/java/com/yoyuzh/auth/User.java +++ b/backend/src/main/java/com/yoyuzh/auth/User.java @@ -40,12 +40,6 @@ public class User { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; - @Column(name = "last_school_student_id", length = 64) - private String lastSchoolStudentId; - - @Column(name = "last_school_semester", length = 64) - private String lastSchoolSemester; - @Column(name = "display_name", nullable = false, length = 64) private String displayName; @@ -135,22 +129,6 @@ public class User { this.createdAt = createdAt; } - public String getLastSchoolStudentId() { - return lastSchoolStudentId; - } - - public void setLastSchoolStudentId(String lastSchoolStudentId) { - this.lastSchoolStudentId = lastSchoolStudentId; - } - - public String getLastSchoolSemester() { - return lastSchoolSemester; - } - - public void setLastSchoolSemester(String lastSchoolSemester) { - this.lastSchoolSemester = lastSchoolSemester; - } - public String getDisplayName() { return displayName; } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java index b01908b..2f650a5 100644 --- a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java @@ -17,10 +17,6 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); - long countByLastSchoolStudentIdIsNotNull(); - - Page findByLastSchoolStudentIdIsNotNull(Pageable pageable); - @Query(""" select u from User u where (:query is null or :query = '' diff --git a/backend/src/main/java/com/yoyuzh/config/CquApiProperties.java b/backend/src/main/java/com/yoyuzh/config/CquApiProperties.java deleted file mode 100644 index b4248e0..0000000 --- a/backend/src/main/java/com/yoyuzh/config/CquApiProperties.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index fdc301a..a78afa6 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -47,9 +48,13 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() + .requestMatchers("/api/transfer/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/files/share-links/*") + .permitAll() .requestMatchers("/api/admin/**") .authenticated() - .requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**") + .requestMatchers("/api/files/**", "/api/user/**") .authenticated() .anyRequest() .permitAll()) diff --git a/backend/src/main/java/com/yoyuzh/cqu/Course.java b/backend/src/main/java/com/yoyuzh/cqu/Course.java deleted file mode 100644 index 9432cf3..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/Course.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java b/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java deleted file mode 100644 index 0453b2f..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java +++ /dev/null @@ -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 { - List findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester); - - Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); - - void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); - - long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java b/backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java deleted file mode 100644 index ea95fa2..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.yoyuzh.cqu; - -public record CourseResponse( - String courseName, - String teacher, - String classroom, - Integer dayOfWeek, - Integer startTime, - Integer endTime -) { -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java b/backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java deleted file mode 100644 index b257bfb..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java +++ /dev/null @@ -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> 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> 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<>() { - }); - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquController.java b/backend/src/main/java/com/yoyuzh/cqu/CquController.java deleted file mode 100644 index 5e41d85..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CquController.java +++ /dev/null @@ -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> 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> 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 latest(@AuthenticationPrincipal UserDetails userDetails) { - return ApiResponse.success(cquDataService.getLatest(resolveUser(userDetails))); - } - - private User resolveUser(UserDetails userDetails) { - return userDetails == null ? null : userDetailsService.loadDomainUser(userDetails.getUsername()); - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java b/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java deleted file mode 100644 index cfa1e20..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java +++ /dev/null @@ -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 getSchedule(User user, String semester, String studentId) { - return getSchedule(user, semester, studentId, false); - } - - @Transactional - public List getSchedule(User user, String semester, String studentId, boolean refresh) { - requireLoginIfNecessary(user); - if (user != null && !refresh) { - List stored = readSavedSchedule(user.getId(), studentId, semester); - if (!stored.isEmpty()) { - rememberLastSchoolQuery(user, studentId, semester); - return stored; - } - } - - List 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 getGrades(User user, String semester, String studentId) { - return getGrades(user, semester, studentId, false); - } - - @Transactional - public List 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 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 schedule = readSavedSchedule(user.getId(), context.studentId(), context.semester()); - List 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 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 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 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 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 latestCourse = courseRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()); - Optional 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 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 source) { - return new GradeResponse( - stringValue(source, "courseName"), - doubleValue(source, "grade"), - stringValue(source, "semester")); - } - - private String stringValue(Map source, String key) { - Object value = source.get(key); - return value == null ? null : value.toString(); - } - - private Integer intValue(Map source, String key) { - Object value = source.get(key); - return value == null ? null : Integer.parseInt(value.toString()); - } - - private Double doubleValue(Map source, String key) { - Object value = source.get(key); - return value == null ? null : Double.parseDouble(value.toString()); - } - - private record QueryContext(String studentId, String semester) { - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java b/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java deleted file mode 100644 index 9099576..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java +++ /dev/null @@ -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> createSchedule(String semester, String studentId) { - StudentProfile profile = StudentProfile.fromStudentId(studentId); - return profile.schedule().stream() - .map(item -> Map.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> createGrades(String semester, String studentId) { - StudentProfile profile = StudentProfile.fromStudentId(studentId); - return profile.grades().stream() - .map(item -> Map.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 schedule, - List 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) - ) - ); - }; - } - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/Grade.java b/backend/src/main/java/com/yoyuzh/cqu/Grade.java deleted file mode 100644 index 63be796..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/Grade.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java b/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java deleted file mode 100644 index 6ae4f6d..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java +++ /dev/null @@ -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 { - List findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId); - - boolean existsByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); - - Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); - - void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); - - long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java b/backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java deleted file mode 100644 index 76ac678..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.yoyuzh.cqu; - -public record GradeResponse( - String courseName, - Double grade, - String semester -) { -} diff --git a/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java b/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java deleted file mode 100644 index 924fc00..0000000 --- a/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.yoyuzh.cqu; - -import java.util.List; - -public record LatestSchoolDataResponse( - String studentId, - String semester, - List schedule, - List grades -) { -} diff --git a/backend/src/main/java/com/yoyuzh/files/CopyFileRequest.java b/backend/src/main/java/com/yoyuzh/files/CopyFileRequest.java new file mode 100644 index 0000000..ef6d916 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/CopyFileRequest.java @@ -0,0 +1,9 @@ +package com.yoyuzh.files; + +import jakarta.validation.constraints.NotBlank; + +public record CopyFileRequest( + @NotBlank(message = "目标路径不能为空") + String path +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/CreateFileShareLinkResponse.java b/backend/src/main/java/com/yoyuzh/files/CreateFileShareLinkResponse.java new file mode 100644 index 0000000..1fa12a7 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/CreateFileShareLinkResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileController.java b/backend/src/main/java/com/yoyuzh/files/FileController.java index d7a45f4..0bd9e34 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileController.java +++ b/backend/src/main/java/com/yoyuzh/files/FileController.java @@ -108,6 +108,53 @@ public class FileController { fileService.rename(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId, request.filename())); } + @Operation(summary = "移动文件") + @PatchMapping("/{fileId}/move") + public ApiResponse 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 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 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 getShareDetails(@PathVariable String token) { + return ApiResponse.success(fileService.getShareDetails(token)); + } + + @Operation(summary = "导入共享文件") + @PostMapping("/share-links/{token}/import") + public ApiResponse importSharedFile(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String token, + @Valid @RequestBody ImportSharedFileRequest request) { + return ApiResponse.success( + fileService.importSharedFile( + userDetailsService.loadDomainUser(userDetails.getUsername()), + token, + request.path() + ) + ); + } + @Operation(summary = "删除文件") @DeleteMapping("/{fileId}") public ApiResponse delete(@AuthenticationPrincipal UserDetails userDetails, diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index e71e35e..6e066ad 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -22,10 +22,12 @@ import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -35,13 +37,16 @@ public class FileService { private final StoredFileRepository storedFileRepository; private final FileContentStorage fileContentStorage; + private final FileShareLinkRepository fileShareLinkRepository; private final long maxFileSize; public FileService(StoredFileRepository storedFileRepository, FileContentStorage fileContentStorage, + FileShareLinkRepository fileShareLinkRepository, FileStorageProperties properties) { this.storedFileRepository = storedFileRepository; this.fileContentStorage = fileContentStorage; + this.fileShareLinkRepository = fileShareLinkRepository; this.maxFileSize = properties.getMaxFileSize(); } @@ -207,6 +212,105 @@ public class FileService { return toResponse(storedFileRepository.save(storedFile)); } + @Transactional + public FileMetadataResponse move(User user, Long fileId, String nextPath) { + StoredFile storedFile = getOwnedFile(user, fileId, "移动"); + String normalizedTargetPath = normalizeDirectoryPath(nextPath); + if (normalizedTargetPath.equals(storedFile.getPath())) { + return toResponse(storedFile); + } + + ensureExistingDirectoryPath(user.getId(), normalizedTargetPath); + if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedTargetPath, storedFile.getFilename())) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); + } + + if (storedFile.isDirectory()) { + String oldLogicalPath = buildLogicalPath(storedFile); + String newLogicalPath = "/".equals(normalizedTargetPath) + ? "/" + storedFile.getFilename() + : normalizedTargetPath + "/" + storedFile.getFilename(); + if (newLogicalPath.equals(oldLogicalPath) || newLogicalPath.startsWith(oldLogicalPath + "/")) { + throw new BusinessException(ErrorCode.UNKNOWN, "不能移动到当前目录或其子目录"); + } + + List 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 descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath); + List copiedEntries = new ArrayList<>(); + + fileContentStorage.ensureDirectory(user.getId(), newLogicalPath); + StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath); + copiedEntries.add(copiedRoot); + + descendants.stream() + .sorted(Comparator + .comparingInt((StoredFile descendant) -> descendant.getPath().length()) + .thenComparing(descendant -> descendant.isDirectory() ? 0 : 1) + .thenComparing(StoredFile::getFilename)) + .forEach(descendant -> { + String copiedPath = remapCopiedPath(descendant.getPath(), oldLogicalPath, newLogicalPath); + if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), copiedPath, descendant.getFilename())) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件"); + } + + if (descendant.isDirectory()) { + fileContentStorage.ensureDirectory(user.getId(), buildTargetLogicalPath(copiedPath, descendant.getFilename())); + } else { + fileContentStorage.copyFile(user.getId(), descendant.getPath(), copiedPath, descendant.getStorageName()); + } + copiedEntries.add(copyStoredFile(descendant, copiedPath)); + }); + + StoredFile savedRoot = null; + for (StoredFile copiedEntry : copiedEntries) { + StoredFile savedEntry = storedFileRepository.save(copiedEntry); + if (savedRoot == null) { + savedRoot = savedEntry; + } + } + return toResponse(savedRoot == null ? copiedRoot : savedRoot); + } + public ResponseEntity download(User user, Long fileId) { StoredFile storedFile = getOwnedFile(user, fileId, "下载"); if (storedFile.isDirectory()) { @@ -249,6 +353,78 @@ public class FileService { return new DownloadUrlResponse("/api/files/download/" + storedFile.getId()); } + @Transactional + public CreateFileShareLinkResponse createShareLink(User user, Long fileId) { + StoredFile storedFile = getOwnedFile(user, fileId, "分享"); + if (storedFile.isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持分享链接"); + } + + FileShareLink shareLink = new FileShareLink(); + shareLink.setOwner(user); + shareLink.setFile(storedFile); + shareLink.setToken(UUID.randomUUID().toString().replace("-", "")); + FileShareLink saved = fileShareLinkRepository.save(shareLink); + + return new CreateFileShareLinkResponse( + saved.getToken(), + storedFile.getFilename(), + storedFile.getSize(), + storedFile.getContentType(), + saved.getCreatedAt() + ); + } + + public FileShareDetailsResponse getShareDetails(String token) { + FileShareLink shareLink = getShareLink(token); + StoredFile storedFile = shareLink.getFile(); + return new FileShareDetailsResponse( + shareLink.getToken(), + shareLink.getOwner().getUsername(), + storedFile.getFilename(), + storedFile.getSize(), + storedFile.getContentType(), + storedFile.isDirectory(), + shareLink.getCreatedAt() + ); + } + + @Transactional + public FileMetadataResponse importSharedFile(User recipient, String token, String path) { + FileShareLink shareLink = getShareLink(token); + StoredFile sourceFile = shareLink.getFile(); + if (sourceFile.isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入"); + } + + String normalizedPath = normalizeDirectoryPath(path); + String filename = normalizeLeafName(sourceFile.getFilename()); + validateUpload(recipient.getId(), normalizedPath, filename, sourceFile.getSize()); + ensureDirectoryHierarchy(recipient, normalizedPath); + + byte[] content = fileContentStorage.readFile( + sourceFile.getUser().getId(), + sourceFile.getPath(), + sourceFile.getStorageName() + ); + fileContentStorage.storeImportedFile( + recipient.getId(), + normalizedPath, + filename, + sourceFile.getContentType(), + content + ); + + return saveFileMetadata( + recipient, + normalizedPath, + filename, + filename, + sourceFile.getContentType(), + sourceFile.getSize() + ); + } + private ResponseEntity downloadDirectory(User user, StoredFile directory) { String logicalPath = buildLogicalPath(directory); String archiveName = directory.getFilename() + ".zip"; @@ -304,6 +480,11 @@ public class FileService { return toResponse(storedFileRepository.save(storedFile)); } + private FileShareLink getShareLink(String token) { + return fileShareLinkRepository.findByToken(token) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "分享链接不存在")); + } + private StoredFile getOwnedFile(User user, Long fileId, String action) { StoredFile storedFile = storedFileRepository.findById(fileId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); @@ -353,6 +534,23 @@ public class FileService { } } + private void ensureExistingDirectoryPath(Long userId, String normalizedPath) { + if ("/".equals(normalizedPath)) { + return; + } + + String[] segments = normalizedPath.substring(1).split("/"); + String currentPath = "/"; + for (String segment : segments) { + StoredFile directory = storedFileRepository.findByUserIdAndPathAndFilename(userId, currentPath, segment) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "目标目录不存在")); + if (!directory.isDirectory()) { + throw new BusinessException(ErrorCode.UNKNOWN, "目标路径不是目录"); + } + currentPath = "/".equals(currentPath) ? "/" + segment : currentPath + "/" + segment; + } + } + private String normalizeUploadFilename(String originalFilename) { String filename = StringUtils.cleanPath(originalFilename); if (!StringUtils.hasText(filename)) { @@ -406,6 +604,31 @@ public class FileService { : storedFile.getPath() + "/" + storedFile.getFilename(); } + private String buildTargetLogicalPath(String normalizedTargetPath, String filename) { + return "/".equals(normalizedTargetPath) + ? "/" + filename + : normalizedTargetPath + "/" + filename; + } + + private String remapCopiedPath(String currentPath, String oldLogicalPath, String newLogicalPath) { + if (currentPath.equals(oldLogicalPath)) { + return newLogicalPath; + } + return newLogicalPath + currentPath.substring(oldLogicalPath.length()); + } + + private StoredFile copyStoredFile(StoredFile source, String nextPath) { + StoredFile copiedFile = new StoredFile(); + copiedFile.setUser(source.getUser()); + copiedFile.setFilename(source.getFilename()); + copiedFile.setPath(nextPath); + copiedFile.setStorageName(source.getStorageName()); + copiedFile.setContentType(source.getContentType()); + copiedFile.setSize(source.getSize()); + copiedFile.setDirectory(source.isDirectory()); + return copiedFile; + } + private String buildZipEntryName(String rootDirectoryName, String rootLogicalPath, StoredFile storedFile) { StringBuilder entryName = new StringBuilder(rootDirectoryName).append('/'); if (!storedFile.getPath().equals(rootLogicalPath)) { diff --git a/backend/src/main/java/com/yoyuzh/files/FileShareDetailsResponse.java b/backend/src/main/java/com/yoyuzh/files/FileShareDetailsResponse.java new file mode 100644 index 0000000..a5e7678 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileShareDetailsResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileShareLink.java b/backend/src/main/java/com/yoyuzh/files/FileShareLink.java new file mode 100644 index 0000000..10eae4f --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileShareLink.java @@ -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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/FileShareLinkRepository.java b/backend/src/main/java/com/yoyuzh/files/FileShareLinkRepository.java new file mode 100644 index 0000000..655d8ce --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/FileShareLinkRepository.java @@ -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 { + + @EntityGraph(attributePaths = {"owner", "file", "file.user"}) + Optional findByToken(String token); +} diff --git a/backend/src/main/java/com/yoyuzh/files/ImportSharedFileRequest.java b/backend/src/main/java/com/yoyuzh/files/ImportSharedFileRequest.java new file mode 100644 index 0000000..48a9b2e --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/ImportSharedFileRequest.java @@ -0,0 +1,6 @@ +package com.yoyuzh.files; + +import jakarta.validation.constraints.NotBlank; + +public record ImportSharedFileRequest(@NotBlank String path) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/MoveFileRequest.java b/backend/src/main/java/com/yoyuzh/files/MoveFileRequest.java new file mode 100644 index 0000000..b2e9e2d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/MoveFileRequest.java @@ -0,0 +1,9 @@ +package com.yoyuzh.files; + +import jakarta.validation.constraints.NotBlank; + +public record MoveFileRequest( + @NotBlank(message = "目标路径不能为空") + String path +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index cfebdb0..7c6b4d2 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface StoredFileRepository extends JpaRepository { @@ -38,6 +39,14 @@ public interface StoredFileRepository extends JpaRepository { @Param("path") String path, @Param("filename") String filename); + @Query(""" + select f from StoredFile f + where f.user.id = :userId and f.path = :path and f.filename = :filename + """) + Optional findByUserIdAndPathAndFilename(@Param("userId") Long userId, + @Param("path") String path, + @Param("filename") String filename); + @Query(""" select f from StoredFile f where f.user.id = :userId and f.path = :path diff --git a/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java b/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java new file mode 100644 index 0000000..86a0b3d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/CreateTransferSessionRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java b/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java new file mode 100644 index 0000000..d8fa5c0 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/LookupTransferSessionResponse.java @@ -0,0 +1,10 @@ +package com.yoyuzh.transfer; + +import java.time.Instant; + +public record LookupTransferSessionResponse( + String sessionId, + String pickupCode, + Instant expiresAt +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/PollTransferSignalsResponse.java b/backend/src/main/java/com/yoyuzh/transfer/PollTransferSignalsResponse.java new file mode 100644 index 0000000..448162e --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/PollTransferSignalsResponse.java @@ -0,0 +1,9 @@ +package com.yoyuzh.transfer; + +import java.util.List; + +public record PollTransferSignalsResponse( + List items, + long nextCursor +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java new file mode 100644 index 0000000..51dd2e1 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java @@ -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 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 lookupSession(@RequestParam String pickupCode) { + return ApiResponse.success(transferService.lookupSession(pickupCode)); + } + + @Operation(summary = "加入快传会话") + @PostMapping("/sessions/{sessionId}/join") + public ApiResponse joinSession(@PathVariable String sessionId) { + return ApiResponse.success(transferService.joinSession(sessionId)); + } + + @Operation(summary = "提交快传信令") + @PostMapping("/sessions/{sessionId}/signals") + public ApiResponse 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 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, "用户未登录"); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java b/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java new file mode 100644 index 0000000..71cb1c8 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferFileItem.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferRole.java b/backend/src/main/java/com/yoyuzh/transfer/TransferRole.java new file mode 100644 index 0000000..20d170b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferRole.java @@ -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, "不支持的传输角色"); + }; + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java new file mode 100644 index 0000000..2145600 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -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 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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java new file mode 100644 index 0000000..3f8ef88 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSession.java @@ -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 files; + private final List senderQueue = new ArrayList<>(); + private final List receiverQueue = new ArrayList<>(); + private boolean receiverJoined; + private long nextSenderCursor = 1; + private long nextReceiverCursor = 1; + + TransferSession(String sessionId, String pickupCode, Instant expiresAt, List 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 queue = role == TransferRole.SENDER ? senderQueue : receiverQueue; + List 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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java new file mode 100644 index 0000000..8ea9def --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionResponse.java @@ -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 files +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java new file mode 100644 index 0000000..b256b89 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSessionStore.java @@ -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 sessionsById = new ConcurrentHashMap<>(); + private final Map sessionIdsByPickupCode = new ConcurrentHashMap<>(); + + public void save(TransferSession session) { + sessionsById.put(session.sessionId(), session); + sessionIdsByPickupCode.put(session.pickupCode(), session.sessionId()); + } + + public Optional findById(String sessionId) { + return Optional.ofNullable(sessionsById.get(sessionId)); + } + + public Optional 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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSignalEnvelope.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSignalEnvelope.java new file mode 100644 index 0000000..f48527b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSignalEnvelope.java @@ -0,0 +1,8 @@ +package com.yoyuzh.transfer; + +public record TransferSignalEnvelope( + long cursor, + String type, + String payload +) { +} diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferSignalRequest.java b/backend/src/main/java/com/yoyuzh/transfer/TransferSignalRequest.java new file mode 100644 index 0000000..ed74786 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferSignalRequest.java @@ -0,0 +1,11 @@ +package com.yoyuzh.transfer; + +import jakarta.validation.constraints.NotBlank; + +public record TransferSignalRequest( + @NotBlank(message = "信令类型不能为空") + String type, + @NotBlank(message = "信令内容不能为空") + String payload +) { +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 8e61361..811e912 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -17,5 +17,3 @@ app: secret: ${APP_JWT_SECRET:} admin: usernames: ${APP_ADMIN_USERNAMES:} - cqu: - mock-enabled: true diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 63b62e5..ca099d9 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -32,10 +32,6 @@ app: storage: root-dir: ./storage max-file-size: 524288000 - cqu: - base-url: https://example-cqu-api.local - require-login: true - mock-enabled: false cors: allowed-origins: - http://localhost:3000 diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 7804a3e..8fb1404 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -33,9 +33,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "spring.jpa.hibernate.ddl-auto=create-drop", "app.jwt.secret=0123456789abcdef0123456789abcdef", "app.admin.usernames=admin", - "app.storage.root-dir=./target/test-storage-admin", - "app.cqu.require-login=true", - "app.cqu.mock-enabled=false" + "app.storage.root-dir=./target/test-storage-admin" } ) @AutoConfigureMockMvc @@ -66,8 +64,6 @@ class AdminControllerIntegrationTest { portalUser.setPhoneNumber("13800138000"); portalUser.setPasswordHash("encoded-password"); portalUser.setCreatedAt(LocalDateTime.now()); - portalUser.setLastSchoolStudentId("20230001"); - portalUser.setLastSchoolSemester("2025-2026-1"); portalUser = userRepository.save(portalUser); secondaryUser = new User(); @@ -109,15 +105,13 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items[0].username").value("alice")) .andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000")) - .andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001")) .andExpect(jsonPath("$.data.items[0].role").value("USER")) .andExpect(jsonPath("$.data.items[0].banned").value(false)); mockMvc.perform(get("/api/admin/summary")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.totalUsers").value(2)) - .andExpect(jsonPath("$.data.totalFiles").value(2)) - .andExpect(jsonPath("$.data.usersWithSchoolCache").value(1)); + .andExpect(jsonPath("$.data.totalFiles").value(2)); } @Test diff --git a/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java b/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java index f44fd94..4996c9f 100644 --- a/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/RefreshTokenServiceIntegrationTest.java @@ -28,9 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; "spring.datasource.password=", "spring.jpa.hibernate.ddl-auto=create-drop", "app.jwt.secret=0123456789abcdef0123456789abcdef", - "app.storage.root-dir=./target/test-storage-refresh", - "app.cqu.require-login=true", - "app.cqu.mock-enabled=false" + "app.storage.root-dir=./target/test-storage-refresh" } ) class RefreshTokenServiceIntegrationTest { diff --git a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java index 0c3b931..4b36bae 100644 --- a/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java +++ b/backend/src/test/java/com/yoyuzh/config/SecurityConfigTest.java @@ -28,4 +28,5 @@ class SecurityConfigTest { assertThat(configuration).isNotNull(); assertThat(configuration.getAllowedMethods()).contains("PATCH"); } + } diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java deleted file mode 100644 index 646f9bf..0000000 --- a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java +++ /dev/null @@ -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 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 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 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"); - } -} diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java deleted file mode 100644 index c283a15..0000000 --- a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java +++ /dev/null @@ -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 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"); - } -} diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java deleted file mode 100644 index 6aa9204..0000000 --- a/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java +++ /dev/null @@ -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> 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> 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> firstSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2023123456"); - List> secondSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2022456789"); - List> firstGrades = CquMockDataFactory.createGrades("2025-2026-1", "2023123456"); - List> 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()); - } -} diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index d91713b..ce34fb5 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -27,6 +27,7 @@ import java.util.zip.ZipInputStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalMatchers.aryEq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; @@ -43,13 +44,16 @@ class FileServiceTest { @Mock private FileContentStorage fileContentStorage; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + private FileService fileService; @BeforeEach void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); - fileService = new FileService(storedFileRepository, fileContentStorage, properties); + fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); } @Test @@ -167,6 +171,140 @@ class FileServiceTest { verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile)); } + @Test + void shouldMoveFileToAnotherDirectory() { + User user = createUser(7L); + StoredFile file = createFile(10L, user, "/docs", "notes.txt"); + StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + FileMetadataResponse response = fileService.move(user, 10L, "/下载"); + + assertThat(response.path()).isEqualTo("/下载"); + assertThat(file.getPath()).isEqualTo("/下载"); + verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt"); + } + + @Test + void shouldMoveDirectoryAndUpdateDescendantPaths() { + User user = createUser(7L); + StoredFile directory = createDirectory(10L, user, "/docs", "archive"); + StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); + StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); + when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(storedFileRepository.saveAll(List.of(childFile))).thenReturn(List.of(childFile)); + + FileMetadataResponse response = fileService.move(user, 10L, "/图片"); + + assertThat(response.path()).isEqualTo("/图片/archive"); + assertThat(directory.getPath()).isEqualTo("/图片"); + assertThat(childFile.getPath()).isEqualTo("/图片/archive"); + verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile)); + } + + @Test + void shouldRejectMovingDirectoryIntoItsOwnDescendant() { + User user = createUser(7L); + StoredFile directory = createDirectory(10L, user, "/docs", "archive"); + StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); + StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); + StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) + .thenReturn(Optional.of(docsDirectory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) + .thenReturn(Optional.of(archiveDirectory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested")) + .thenReturn(Optional.of(descendantDirectory)); + + assertThatThrownBy(() -> fileService.move(user, 10L, "/docs/archive/nested")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("不能移动到当前目录或其子目录"); + } + + @Test + void shouldCopyFileToAnotherDirectory() { + User user = createUser(7L); + StoredFile file = createFile(10L, user, "/docs", "notes.txt"); + StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile storedFile = invocation.getArgument(0); + if (storedFile.getId() == null) { + storedFile.setId(20L); + } + return storedFile; + }); + + FileMetadataResponse response = fileService.copy(user, 10L, "/下载"); + + assertThat(response.id()).isEqualTo(20L); + assertThat(response.path()).isEqualTo("/下载"); + verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt"); + } + + @Test + void shouldCopyDirectoryAndDescendants() { + User user = createUser(7L); + StoredFile directory = createDirectory(10L, user, "/docs", "archive"); + StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); + StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested"); + StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt"); + StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); + when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")) + .thenReturn(List.of(childDirectory, childFile, nestedFile)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "nested")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "notes.txt")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive/nested", "todo.txt")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile storedFile = invocation.getArgument(0); + if (storedFile.getId() == null) { + storedFile.setId(100L + storedFile.getFilename().length()); + } + return storedFile; + }); + + FileMetadataResponse response = fileService.copy(user, 10L, "/图片"); + + assertThat(response.path()).isEqualTo("/图片/archive"); + verify(fileContentStorage).ensureDirectory(7L, "/图片/archive"); + verify(fileContentStorage).ensureDirectory(7L, "/图片/archive/nested"); + verify(fileContentStorage).copyFile(7L, "/docs/archive", "/图片/archive", "notes.txt"); + verify(fileContentStorage).copyFile(7L, "/docs/archive/nested", "/图片/archive/nested", "todo.txt"); + } + + @Test + void shouldRejectCopyingDirectoryIntoItsOwnDescendant() { + User user = createUser(7L); + StoredFile directory = createDirectory(10L, user, "/docs", "archive"); + StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); + StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); + StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) + .thenReturn(Optional.of(docsDirectory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) + .thenReturn(Optional.of(archiveDirectory)); + when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested")) + .thenReturn(Optional.of(descendantDirectory)); + + assertThatThrownBy(() -> fileService.copy(user, 10L, "/docs/archive/nested")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("不能复制到当前目录或其子目录"); + } + @Test void shouldRejectDeletingOtherUsersFile() { User owner = createUser(1L); @@ -293,6 +431,60 @@ class FileServiceTest { verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt"); } + @Test + void shouldCreateShareLinkForOwnedFile() { + User user = createUser(7L); + StoredFile file = createFile(22L, user, "/docs", "notes.txt"); + when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file)); + when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> { + FileShareLink shareLink = invocation.getArgument(0); + shareLink.setId(100L); + shareLink.setToken("share-token-1"); + return shareLink; + }); + + CreateFileShareLinkResponse response = fileService.createShareLink(user, 22L); + + assertThat(response.token()).isEqualTo("share-token-1"); + assertThat(response.filename()).isEqualTo("notes.txt"); + verify(fileShareLinkRepository).save(any(FileShareLink.class)); + } + + @Test + void shouldImportSharedFileIntoRecipientWorkspace() { + User owner = createUser(7L); + User recipient = createUser(8L); + StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt"); + FileShareLink shareLink = new FileShareLink(); + shareLink.setId(100L); + shareLink.setToken("share-token-1"); + shareLink.setOwner(owner); + shareLink.setFile(sourceFile); + shareLink.setCreatedAt(LocalDateTime.now()); + when(fileShareLinkRepository.findByToken("share-token-1")).thenReturn(Optional.of(shareLink)); + when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { + StoredFile file = invocation.getArgument(0); + file.setId(200L); + return file; + }); + when(fileContentStorage.readFile(7L, "/docs", "notes.txt")) + .thenReturn("hello".getBytes(StandardCharsets.UTF_8)); + + FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载"); + + assertThat(response.id()).isEqualTo(200L); + assertThat(response.path()).isEqualTo("/下载"); + assertThat(response.filename()).isEqualTo("notes.txt"); + verify(fileContentStorage).storeImportedFile( + eq(8L), + eq("/下载"), + eq("notes.txt"), + eq(sourceFile.getContentType()), + aryEq("hello".getBytes(StandardCharsets.UTF_8)) + ); + } + private User createUser(Long id) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java new file mode 100644 index 0000000..808a34b --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java new file mode 100644 index 0000000..264b706 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java new file mode 100644 index 0000000..5ed420d --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferSessionTest.java @@ -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\"}")); + } +} diff --git a/docs/superpowers/plans/2026-03-20-file-share-and-transfer-save.md b/docs/superpowers/plans/2026-03-20-file-share-and-transfer-save.md new file mode 100644 index 0000000..e5ee705 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-file-share-and-transfer-save.md @@ -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 recipient’s 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 user’s 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 user’s 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** diff --git a/docs/superpowers/plans/2026-03-20-netdisk-path-picker-and-move.md b/docs/superpowers/plans/2026-03-20-netdisk-path-picker-and-move.md new file mode 100644 index 0000000..4ccd0b0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-netdisk-path-picker-and-move.md @@ -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`** diff --git a/docs/superpowers/plans/2026-03-20-transfer-module-refactor.md b/docs/superpowers/plans/2026-03-20-transfer-module-refactor.md new file mode 100644 index 0000000..e25335a --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-transfer-module-refactor.md @@ -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` diff --git a/docs/superpowers/plans/2026-03-20-transfer-webrtc-share.md b/docs/superpowers/plans/2026-03-20-transfer-webrtc-share.md new file mode 100644 index 0000000..6725b2e --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-transfer-webrtc-share.md @@ -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** diff --git a/front/metadata.json b/front/metadata.json index c2590a5..c9ce51b 100644 --- a/front/metadata.json +++ b/front/metadata.json @@ -1,5 +1,5 @@ { "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": [] } diff --git a/front/src/App.tsx b/front/src/App.tsx index 3b84353..302d899 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,19 +1,34 @@ 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 { useAuth } from './auth/AuthProvider'; import Login from './pages/Login'; import Overview from './pages/Overview'; 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 { 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')); +function LegacyTransferRedirect() { + const location = useLocation(); + return ; +} + function AppRoutes() { 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 (
正在检查登录状态... @@ -25,6 +40,12 @@ function AppRoutes() { return ( + : } + /> + } /> + } /> : } @@ -36,7 +57,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> + - + ); } diff --git a/front/src/admin/AdminApp.tsx b/front/src/admin/AdminApp.tsx index 40fa573..0c1d3ea 100644 --- a/front/src/admin/AdminApp.tsx +++ b/front/src/admin/AdminApp.tsx @@ -1,6 +1,5 @@ import FolderOutlined from '@mui/icons-material/FolderOutlined'; import GroupsOutlined from '@mui/icons-material/GroupsOutlined'; -import SchoolOutlined from '@mui/icons-material/SchoolOutlined'; import { Admin, Resource } from 'react-admin'; import { portalAdminAuthProvider } from './auth-provider'; @@ -8,7 +7,6 @@ import { portalAdminDataProvider } from './data-provider'; import { PortalAdminDashboard } from './dashboard'; import { PortalAdminFilesList } from './files-list'; import { PortalAdminUsersList } from './users-list'; -import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list'; export default function PortalAdminApp() { return ( @@ -35,13 +33,6 @@ export default function PortalAdminApp() { options={{ label: '文件资源' }} recordRepresentation="filename" /> - ); } diff --git a/front/src/admin/dashboard.tsx b/front/src/admin/dashboard.tsx index 7cf725f..aa7efad 100644 --- a/front/src/admin/dashboard.tsx +++ b/front/src/admin/dashboard.tsx @@ -17,12 +17,12 @@ const DASHBOARD_ITEMS = [ }, { title: '用户管理', - description: '已接入 /api/admin/users,可查看用户、邮箱与最近教务缓存标记。', + description: '已接入 /api/admin/users,可查看账号、邮箱、手机号与权限状态。', status: 'connected', }, { - title: '教务快照', - description: '已接入 /api/admin/school-snapshots,可查看最近学号、学期和缓存条数。', + title: '门户运营', + description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。', status: 'connected', }, ]; @@ -147,9 +147,6 @@ export function PortalAdminDashboard() { 文件总数:{state.summary?.totalFiles ?? 0} - - 有教务缓存的用户:{state.summary?.usersWithSchoolCache ?? 0} - diff --git a/front/src/admin/data-provider.test.ts b/front/src/admin/data-provider.test.ts index 822c2db..a2ca1c8 100644 --- a/front/src/admin/data-provider.test.ts +++ b/front/src/admin/data-provider.test.ts @@ -76,17 +76,6 @@ test('buildAdminListPath maps generic admin resources to backend paging queries' }), '/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', () => { @@ -103,3 +92,17 @@ test('buildAdminListPath includes the user search query when present', () => { '/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/, + ); +}); diff --git a/front/src/admin/data-provider.ts b/front/src/admin/data-provider.ts index 65d4473..b345681 100644 --- a/front/src/admin/data-provider.ts +++ b/front/src/admin/data-provider.ts @@ -3,21 +3,19 @@ import type { DataProvider, GetListParams, GetListResult, Identifier } from 'rea import { apiRequest } from '@/src/lib/api'; import type { AdminFile, - AdminSchoolSnapshot, AdminUser, PageResponse, } from '@/src/lib/types'; const FILES_RESOURCE = 'files'; const USERS_RESOURCE = 'users'; -const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots'; function createUnsupportedError(resource: string, action: string) { return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`); } 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); } } @@ -35,10 +33,6 @@ export function buildAdminListPath(resource: string, params: Pick>(buildAdminListPath(resource, params)); - return { - data: payload.items, - total: payload.total, - } as GetListResult; + throw createUnsupportedError(resource, 'list'); }, getOne: async (resource) => { ensureSupportedResource(resource, 'getOne'); diff --git a/front/src/admin/school-snapshots-list.tsx b/front/src/admin/school-snapshots-list.tsx deleted file mode 100644 index e1e3678..0000000 --- a/front/src/admin/school-snapshots-list.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Datagrid, List, NumberField, TextField } from 'react-admin'; - -export function PortalAdminSchoolSnapshotsList() { - return ( - - - - - - - - - - - - ); -} diff --git a/front/src/admin/users-list.tsx b/front/src/admin/users-list.tsx index 7afbd6c..6b58df3 100644 --- a/front/src/admin/users-list.tsx +++ b/front/src/admin/users-list.tsx @@ -177,8 +177,6 @@ export function PortalAdminUsersList() { /> )} /> - - label="操作" render={(record) => } /> diff --git a/front/src/auth/admin-access.test.ts b/front/src/auth/admin-access.test.ts index 928a2d2..8ce0590 100644 --- a/front/src/auth/admin-access.test.ts +++ b/front/src/auth/admin-access.test.ts @@ -9,7 +9,6 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed const request = async () => ({ totalUsers: 1, totalFiles: 2, - usersWithSchoolCache: 3, }); await assert.doesNotReject(async () => { diff --git a/front/src/components/layout/Layout.test.ts b/front/src/components/layout/Layout.test.ts index 0869b66..35c749b 100644 --- a/front/src/components/layout/Layout.test.ts +++ b/front/src/components/layout/Layout.test.ts @@ -3,6 +3,14 @@ import test from 'node:test'; 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', () => { assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false); }); diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index 9708a6f..8a3658d 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -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 { Gamepad2, FolderOpen, - GraduationCap, Key, LayoutDashboard, LogOut, Mail, + Send, Settings, Shield, Smartphone, @@ -28,7 +28,7 @@ import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './acc const NAV_ITEMS = [ { name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '网盘', path: '/files', icon: FolderOpen }, - { name: '教务', path: '/school', icon: GraduationCap }, + { name: '快传', path: '/transfer', icon: Send }, { name: '游戏', path: '/games', icon: Gamepad2 }, { name: '后台', path: '/admin', icon: Shield }, ] as const; @@ -39,7 +39,11 @@ export function getVisibleNavItems(isAdmin: boolean) { 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 { isAdmin, logout, refreshProfile, user } = useAuth(); const navItems = getVisibleNavItems(isAdmin); @@ -328,7 +332,7 @@ export function Layout() {
-
+
@@ -427,8 +431,8 @@ export function Layout() {
-
- +
+ {children ?? }
diff --git a/front/src/components/ui/NetdiskPathPickerModal.tsx b/front/src/components/ui/NetdiskPathPickerModal.tsx new file mode 100644 index 0000000..9ef75a1 --- /dev/null +++ b/front/src/components/ui/NetdiskPathPickerModal.tsx @@ -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; +} + +export function NetdiskPathPickerModal({ + isOpen, + title, + description, + initialPath = '/', + confirmLabel, + confirmPathPreview, + onClose, + onConfirm, +}: NetdiskPathPickerModalProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + const [folders, setFolders] = useState([]); + 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>( + `/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( + + {isOpen ? ( +
+ +
+
+

{title}

+ {description ?

{description}

: null} +
+ +
+ +
+
+
+
+

当前目录

+
+ + {pathSegments.map((segment, index) => ( + + + + + ))} +
+
+ +
+

将存入: {previewPath}

+
+ +
+
选择目标文件夹
+
+ {loading ? ( +
+ + 正在加载目录... +
+ ) : folders.length === 0 ? ( +
这个目录下没有更多子文件夹,当前目录也可以直接使用。
+ ) : ( +
+ {folders.map((folder) => { + const nextPath = folder.path; + return ( + + ); + })} +
+ )} +
+
+ + {error ? ( +
{error}
+ ) : null} + +
+ + +
+
+
+
+ ) : null} +
+ , + document.body, + ); +} diff --git a/front/src/lib/cache.test.ts b/front/src/lib/cache.test.ts index b7c59b1..101e882 100644 --- a/front/src/lib/cache.test.ts +++ b/front/src/lib/cache.test.ts @@ -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', () => { @@ -73,9 +73,9 @@ test('cached values are isolated between users', () => { createdAt: '2026-03-14T12:00:00', }, }); - writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), { + writeCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201'), { queried: true, - grades: [95], + sharedFiles: [2], }); 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', () => { - 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(localStorage.getItem('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:transfer:pickup-code:849201'), null); }); diff --git a/front/src/lib/file-copy.ts b/front/src/lib/file-copy.ts new file mode 100644 index 0000000..c7d9ff7 --- /dev/null +++ b/front/src/lib/file-copy.ts @@ -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(`/files/${fileId}/copy`, { + method: 'POST', + body: { + path: normalizeNetdiskTargetPath(path, '/'), + }, + }); +} diff --git a/front/src/lib/file-move.ts b/front/src/lib/file-move.ts new file mode 100644 index 0000000..078bd60 --- /dev/null +++ b/front/src/lib/file-move.ts @@ -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(`/files/${fileId}/move`, { + method: 'PATCH', + body: { + path: normalizeNetdiskTargetPath(path, '/'), + }, + }); +} diff --git a/front/src/lib/file-share.test.ts b/front/src/lib/file-share.test.ts new file mode 100644 index 0000000..026a5d2 --- /dev/null +++ b/front/src/lib/file-share.test.ts @@ -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'); +}); diff --git a/front/src/lib/file-share.ts b/front/src/lib/file-share.ts new file mode 100644 index 0000000..f0c8d6a --- /dev/null +++ b/front/src/lib/file-share.ts @@ -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(`/files/${fileId}/share-links`, { + method: 'POST', + }); +} + +export function getFileShareDetails(token: string) { + return apiRequest(`/files/share-links/${encodeURIComponent(token)}`); +} + +export function importSharedFile(token: string, path: string) { + return apiRequest(`/files/share-links/${encodeURIComponent(token)}/import`, { + method: 'POST', + body: { path }, + }); +} + +export function getCurrentFileShareUrl(token: string) { + return buildFileShareUrl(window.location.origin, token, getTransferRouterMode()); +} diff --git a/front/src/lib/netdisk-paths.test.ts b/front/src/lib/netdisk-paths.test.ts new file mode 100644 index 0000000..99447fe --- /dev/null +++ b/front/src/lib/netdisk-paths.test.ts @@ -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', '/下载'), '/下载'); +}); diff --git a/front/src/lib/netdisk-paths.ts b/front/src/lib/netdisk-paths.ts new file mode 100644 index 0000000..c9974d4 --- /dev/null +++ b/front/src/lib/netdisk-paths.ts @@ -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)]); +} diff --git a/front/src/lib/netdisk-upload.test.ts b/front/src/lib/netdisk-upload.test.ts new file mode 100644 index 0000000..6c71959 --- /dev/null +++ b/front/src/lib/netdisk-upload.test.ts @@ -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'), '/下载/相册/旅行'); +}); diff --git a/front/src/lib/netdisk-upload.ts b/front/src/lib/netdisk-upload.ts new file mode 100644 index 0000000..9630e08 --- /dev/null +++ b/front/src/lib/netdisk-upload.ts @@ -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('/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('/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(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, { + body: formData, + }); +} diff --git a/front/src/lib/page-cache.ts b/front/src/lib/page-cache.ts index 344bc25..5f93222 100644 --- a/front/src/lib/page-cache.ts +++ b/front/src/lib/page-cache.ts @@ -1,41 +1,10 @@ import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache'; -import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types'; - -export interface SchoolQueryCache { - studentId: string; - semester: string; -} - -export interface SchoolResultsCache { - queried: boolean; - schedule: CourseResponse[]; - grades: GradeResponse[]; - studentId: string; - semester: string; -} +import type { FileMetadata, UserProfile } from './types'; export interface OverviewCache { profile: UserProfile | null; recentFiles: FileMetadata[]; rootFiles: FileMetadata[]; - schedule: CourseResponse[]; - grades: GradeResponse[]; -} - -function getSchoolQueryCacheKey() { - return buildScopedCacheKey('school-query'); -} - -export function readStoredSchoolQuery() { - return readCachedValue(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() { diff --git a/front/src/lib/schedule-table.test.ts b/front/src/lib/schedule-table.test.ts deleted file mode 100644 index 2f0b0de..0000000 --- a/front/src/lib/schedule-table.test.ts +++ /dev/null @@ -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]); -}); diff --git a/front/src/lib/schedule-table.ts b/front/src/lib/schedule-table.ts deleted file mode 100644 index 87bc63d..0000000 --- a/front/src/lib/schedule-table.ts +++ /dev/null @@ -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, - ); -} diff --git a/front/src/lib/school.ts b/front/src/lib/school.ts deleted file mode 100644 index 5c65363..0000000 --- a/front/src/lib/school.ts +++ /dev/null @@ -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('/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, - }); -} diff --git a/front/src/lib/transfer-archive.test.ts b/front/src/lib/transfer-archive.test.ts new file mode 100644 index 0000000..228300f --- /dev/null +++ b/front/src/lib/transfer-archive.test.ts @@ -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/); +}); diff --git a/front/src/lib/transfer-archive.ts b/front/src/lib/transfer-archive.ts new file mode 100644 index 0000000..9199b7a --- /dev/null +++ b/front/src/lib/transfer-archive.ts @@ -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', + }); +} diff --git a/front/src/lib/transfer-links.ts b/front/src/lib/transfer-links.ts new file mode 100644 index 0000000..1f01821 --- /dev/null +++ b/front/src/lib/transfer-links.ts @@ -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}`; +} diff --git a/front/src/lib/transfer-protocol.test.ts b/front/src/lib/transfer-protocol.test.ts new file mode 100644 index 0000000..fe050a8 --- /dev/null +++ b/front/src/lib/transfer-protocol.test.ts @@ -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]); +}); diff --git a/front/src/lib/transfer-protocol.ts b/front/src/lib/transfer-protocol.ts new file mode 100644 index 0000000..29cff9a --- /dev/null +++ b/front/src/lib/transfer-protocol.ts @@ -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); +} diff --git a/front/src/lib/transfer-runtime.ts b/front/src/lib/transfer-runtime.ts new file mode 100644 index 0000000..acb7f12 --- /dev/null +++ b/front/src/lib/transfer-runtime.ts @@ -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((resolve) => { + const timer = window.setInterval(() => { + if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) { + window.clearInterval(timer); + resolve(); + } + }, 40); + }); +} diff --git a/front/src/lib/transfer-signaling.test.ts b/front/src/lib/transfer-signaling.test.ts new file mode 100644 index 0000000..7354ab1 --- /dev/null +++ b/front/src/lib/transfer-signaling.test.ts @@ -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, []); +}); diff --git a/front/src/lib/transfer-signaling.ts b/front/src/lib/transfer-signaling.ts new file mode 100644 index 0000000..2ee7221 --- /dev/null +++ b/front/src/lib/transfer-signaling.ts @@ -0,0 +1,32 @@ +interface RemoteIceCapableConnection { + remoteDescription: RTCSessionDescription | null; + addIceCandidate(candidate: RTCIceCandidateInit): Promise; +} + +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 []; +} diff --git a/front/src/lib/transfer.ts b/front/src/lib/transfer.ts new file mode 100644 index 0000000..f0a77be --- /dev/null +++ b/front/src/lib/transfer.ts @@ -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('/transfer/sessions', { + method: 'POST', + body: { + files: toTransferFilePayload(files), + }, + }); +} + +export function lookupTransferSession(pickupCode: string) { + return apiRequest( + `/transfer/sessions/lookup?pickupCode=${encodeURIComponent(pickupCode)}`, + ); +} + +export function joinTransferSession(sessionId: string) { + return apiRequest(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, { + method: 'POST', + }); +} + +export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) { + return apiRequest(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, { + method: 'POST', + body: { + type, + payload, + }, + }); +} + +export function pollTransferSignals(sessionId: string, role: 'sender' | 'receiver', after: number) { + return apiRequest( + `/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}&after=${after}`, + ); +} diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index 1fee6f0..ccaf53a 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -16,7 +16,6 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN'; export interface AdminSummary { totalUsers: number; totalFiles: number; - usersWithSchoolCache: number; } export interface AdminUser { @@ -25,8 +24,6 @@ export interface AdminUser { email: string; phoneNumber: string | null; createdAt: string; - lastSchoolStudentId: string | null; - lastSchoolSemester: string | null; role: AdminUserRole; banned: boolean; } @@ -44,17 +41,6 @@ export interface AdminFile { 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 { temporaryPassword: string; } @@ -101,24 +87,50 @@ export interface DownloadUrlResponse { url: string; } -export interface CourseResponse { - courseName: string; - teacher: string | null; - classroom: string | null; - dayOfWeek: number | null; - startTime: number | null; - endTime: number | null; +export interface CreateFileShareLinkResponse { + token: string; + filename: string; + size: number; + contentType: string | null; + createdAt: string; } -export interface GradeResponse { - courseName: string; - grade: number | null; - semester: string | null; +export interface FileShareDetailsResponse { + token: string; + ownerUsername: string; + filename: string; + size: number; + contentType: string | null; + directory: boolean; + createdAt: string; } -export interface LatestSchoolDataResponse { - studentId: string; - semester: string; - schedule: CourseResponse[]; - grades: GradeResponse[]; +export interface TransferFileItem { + name: string; + size: number; + contentType: string; +} + +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; } diff --git a/front/src/pages/FileShare.tsx b/front/src/pages/FileShare.tsx new file mode 100644 index 0000000..b4af05d --- /dev/null +++ b/front/src/pages/FileShare.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [path, setPath] = useState('/下载'); + const [importing, setImporting] = useState(false); + const [importedFile, setImportedFile] = useState(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 ( +
+
+
+
+ +
+

网盘分享导入

+

打开分享链接后,可以把别人分享给你的文件直接导入到自己的网盘。

+
+ +
+ {loading ? ( +
+ + 正在读取分享详情... +
+ ) : error ? ( +
+ {error} +
+ ) : details ? ( +
+
+
+
+ +
+
+

{details.filename}

+

+ 来自 {details.ownerUsername} · {formatFileSize(details.size)} +

+

+ 创建于 {new Date(details.createdAt).toLocaleString('zh-CN')} +

+
+
+
+ + {!session?.token ? ( +
+

登录后才能把这个文件导入你的网盘。

+ +
+ ) : ( +
+
+

存入位置

+

{path}

+

点击下方按钮后,会弹出目录选择窗口。

+
+ + {importedFile ? ( +
+
+ + 已导入到 {importedFile.path}/{importedFile.filename} +
+ +
+ ) : ( + + )} +
+ )} +
+ ) : null} +
+
+ + setPathPickerOpen(false)} + onConfirm={handleImportToPath} + /> +
+ ); +} diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index 4bdda8a..f664902 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -18,17 +18,23 @@ import { LayoutGrid, List, MoreVertical, + Copy, + Share2, TriangleAlert, X, Edit2, Trash2, } from 'lucide-react'; +import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal'; import { Button } from '@/src/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { Input } from '@/src/components/ui/input'; 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 { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share'; import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; @@ -122,6 +128,7 @@ function toUiFile(file: FileMetadata) { } type UiFile = ReturnType; +type NetdiskTargetAction = 'move' | 'copy'; export default function Files() { const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; @@ -139,11 +146,14 @@ export default function Files() { const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [fileToRename, setFileToRename] = useState(null); const [fileToDelete, setFileToDelete] = useState(null); + const [targetActionFile, setTargetActionFile] = useState(null); + const [targetAction, setTargetAction] = useState(null); const [newFileName, setNewFileName] = useState(''); const [activeDropdown, setActiveDropdown] = useState(null); const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); const [renameError, setRenameError] = useState(''); const [isRenaming, setIsRenaming] = useState(false); + const [shareStatus, setShareStatus] = useState(''); const loadCurrentPath = async (pathParts: string[]) => { const response = await apiRequest>( @@ -210,6 +220,12 @@ export default function Files() { setDeleteModalOpen(true); }; + const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => { + setTargetAction(action); + setTargetActionFile(file); + setActiveDropdown(null); + }; + const handleUploadClick = () => { fileInputRef.current?.click(); }; @@ -478,6 +494,23 @@ export default function Files() { 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) => { if (!targetFile) { return; @@ -526,6 +559,21 @@ export default function Files() { 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 (
{/* Left Sidebar */} @@ -591,6 +639,9 @@ export default function Files() { ))}
+ {shareStatus ? ( +
{shareStatus}
+ ) : null}
+ ) : null} + + )} + {shareStatus && selectedFile.type !== 'folder' ? ( +
+ {shareStatus} +
+ ) : null}
@@ -1024,6 +1097,23 @@ export default function Files() {
)} + + { + setTargetAction(null); + setTargetActionFile(null); + }} + onConfirm={handleMoveToPath} + /> ); } @@ -1042,6 +1132,9 @@ function FileActionMenu({ activeDropdown, onToggle, onDownload, + onShare, + onMove, + onCopy, onRename, onDelete, onClose, @@ -1050,6 +1143,9 @@ function FileActionMenu({ activeDropdown: number | null; onToggle: (fileId: number) => void; onDownload: (file: UiFile) => Promise; + onShare: (file: UiFile) => Promise; + onMove: (file: UiFile) => void; + onCopy: (file: UiFile) => void; onRename: (file: UiFile) => void; onDelete: (file: UiFile) => void; onClose: () => void; @@ -1093,6 +1189,38 @@ function FileActionMenu({ > {file.type === 'folder' ? '下载文件夹' : '下载文件'} + {file.type !== 'folder' ? ( + + ) : null} + + - - - - -
- {previewCourses.map((course, i) => ( -
-
- 第 {course.startTime ?? '--'} - {course.endTime ?? '--'} 节 + + +
+
+
+
+
+ + 新功能
-
-

{course.courseName}

-

- {course.classroom ?? '教室待定'} +

+

P2P 快传工作台

+

+ 现在可以直接从门户里生成取件码、复制分享链接,并在另一台设备上模拟接收流程。

+
+ 拖拽发送 + 临时取件码 + 浏览器接收 +
- ))} - {previewCourses.length === 0 && ( -
- 暂无课程数据,请先前往教务页查询 -
- )} + +
- {/* Right Column */}
- {/* Quick Actions */} 快捷操作 @@ -410,12 +316,11 @@ export default function Overview() { navigate('/files')} /> navigate('/files')} /> navigate('/files')} /> - navigate('/school')} /> + navigate('/transfer')} />
- {/* Storage */} 存储空间 @@ -436,25 +341,34 @@ export default function Overview() { - {/* Account Info */} 账号信息
-
- {avatarUrl ? ( - Avatar - ) : ( - profileAvatarFallback - )} +
+ {avatarUrl ? Avatar : profileAvatarFallback}

{profileDisplayName}

{profile?.email ?? '暂无邮箱'}

+
+
+ + {profile?.username ?? '未登录'} +
+
+ + {profile?.email ?? '暂无邮箱'} +
+
+ + {latestFile ? `最近一次文件更新:${formatRecentTime(latestFile.createdAt)}` : '最近还没有文件变动'} +
+
@@ -463,13 +377,21 @@ export default function Overview() { ); } -function MetricCard({ title, value, desc, icon: Icon, delay }: any) { +function MetricCard({ + title, + value, + desc, + icon: Icon, + delay, +}: { + title: string; + value: string; + desc: string; + icon: React.ComponentType<{ className?: string }>; + delay: number; +}) { return ( - +
@@ -488,7 +410,15 @@ function MetricCard({ title, value, desc, icon: Icon, delay }: any) { ); } -function QuickAction({ icon: Icon, label, onClick }: any) { +function QuickAction({ + icon: Icon, + label, + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +}) { return ( - -
- -
-
- - - - - - 数据摘要 - - 展示当前缓存或最近一次查询结果。 - - - {queried ? ( -
- - - -
- ) : ( -
- -

暂无缓存数据,请先执行查询。

-
- )} -
-
-
- -
- - -
- - - {activeTab === 'schedule' ? : } - -
- ); -} - -function DatabaseIcon(props: React.SVGProps) { - return ( - - - - - - ); -} - -function SummaryItem({ - label, - value, - icon: Icon, -}: { - label: string; - value: string; - icon: React.ComponentType<{ className?: string }>; -}) { - return ( -
-
- -
-
-

{label}

-

{value}

-
-
- ); -} - -function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { - if (!queried) { - return ( - - - -

请先查询课表

-
-
- ); - } - - const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - const periodLabels: Record<'morning' | 'noon' | 'afternoon' | 'evening', string> = { - morning: '上午', - noon: '中午', - afternoon: '下午', - evening: '晚上', - }; - const periodOrder = ['morning', 'noon', 'afternoon', 'evening'] as const; - const rows = buildScheduleTable(schedule); - - return ( - - -
-
- 本周课表 - 周一到周日完整展示,空白节次保持固定格子,跨节课程会按节数占满网格。 -
-
- 上午 1-4 节 - 中午 5 节 - 下午 6-9 节 - 晚上 10-12 节 -
-
-
- -
-
-
- 时段 -
-
- 节次 -
- {days.map((day) => ( -
- {day} -
- ))} - - {periodOrder.map((period, index) => ( -
-
- {periodLabels[period]} -
-
- ))} - - {rows.map((row) => ( -
- Section - {row.section} -
- ))} - - {rows.flatMap((row) => - row.slots.map((slot, columnIndex) => { - if (slot.type !== 'empty') { - return null; - } - - return ( -
- ); - }), - )} - - {rows.flatMap((row) => - row.slots.map((slot, columnIndex) => { - if (slot.type !== 'course') { - return null; - } - - const theme = getCourseTheme(slot.course?.courseName); - const rowSpan = slot.rowSpan ?? 1; - - return ( -
-
-
-

- {slot.course?.courseName} -

- - {formatSections(slot.course?.startTime, slot.course?.endTime)} - -
-
-

- - {slot.course?.classroom ?? '教室待定'} -

-

- - {slot.course?.teacher ?? '教师待定'} -

-
-
- ); - }), - )} -
-
- - - ); -} - -function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { - if (!queried) { - return ( - - - -

请先查询成绩

-
-
- ); - } - - const terms = grades.reduce>((accumulator, grade) => { - const semester = grade.semester ?? '未分类'; - if (!accumulator[semester]) { - accumulator[semester] = []; - } - accumulator[semester].push(grade.grade ?? 0); - return accumulator; - }, {}); - - const getScoreStyle = (score: number) => { - if (score >= 95) return 'bg-[#336EFF]/50 text-white'; - if (score >= 90) return 'bg-[#336EFF]/40 text-white/90'; - if (score >= 85) return 'bg-[#336EFF]/30 text-white/80'; - if (score >= 80) return 'bg-slate-700/60 text-white/70'; - if (score >= 75) return 'bg-slate-700/40 text-white/60'; - return 'bg-slate-800/60 text-white/50'; - }; - - return ( - - - 成绩热力图 - - -
- {Object.entries(terms).map(([term, scores]) => ( -
-

{term}

-
- {scores.map((score, index) => ( -
- {score} -
- ))} -
-
- ))} - {Object.keys(terms).length === 0 ?
暂无成绩数据
: null} -
-
-
- ); -} - - diff --git a/front/src/pages/Transfer.tsx b/front/src/pages/Transfer.tsx new file mode 100644 index 0000000..b7f4df6 --- /dev/null +++ b/front/src/pages/Transfer.tsx @@ -0,0 +1,691 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + CheckCircle, + Copy, + DownloadCloud, + File as FileIcon, + Folder, + FolderPlus, + Link as LinkIcon, + Loader2, + Monitor, + Plus, + Send, + Shield, + Smartphone, + Trash2, + UploadCloud, + X, +} from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; + +import { useAuth } from '@/src/auth/AuthProvider'; +import { Button } from '@/src/components/ui/button'; +import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links'; +import { + createTransferFileManifest, + createTransferFileManifestMessage, + createTransferCompleteMessage, + createTransferFileCompleteMessage, + createTransferFileId, + createTransferFileMetaMessage, + type TransferFileDescriptor, + SIGNAL_POLL_INTERVAL_MS, + TRANSFER_CHUNK_SIZE, +} from '@/src/lib/transfer-protocol'; +import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime'; +import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; +import { DEFAULT_TRANSFER_ICE_SERVERS, createTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer'; +import type { TransferSessionResponse } from '@/src/lib/types'; +import { cn } from '@/src/lib/utils'; + +import { + buildQrImageUrl, + canSendTransferFiles, + formatTransferSize, + resolveInitialTransferTab, +} from './transfer-state'; +import TransferReceive from './TransferReceive'; + +type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'transferring' | 'completed' | 'error'; + +function parseJsonPayload(payload: string): T | null { + try { + return JSON.parse(payload) as T; + } catch { + return null; + } +} + +function getPhaseMessage(phase: SendPhase, errorMessage: string) { + switch (phase) { + case 'creating': + return '正在创建快传会话并准备 P2P 连接...'; + case 'waiting': + return '分享链接和二维码已经生成,等待接收端打开页面并选择要接收的文件。'; + case 'connecting': + return '接收端已进入页面,正在交换浏览器连接信息并同步文件清单...'; + case 'transferring': + return 'P2P 直连已建立,文件正在发送到对方浏览器。'; + case 'completed': + return '本次文件已发送完成,对方页面现在可以下载。'; + case 'error': + return errorMessage || '快传会话初始化失败,请重试。'; + default: + return '拖拽文件后会自动生成会话、二维码和公开接收页链接。'; + } +} + +export default function Transfer() { + const { session: authSession } = useAuth(); + const [searchParams] = useSearchParams(); + const sessionId = searchParams.get('session'); + const allowSend = canSendTransferFiles(Boolean(authSession?.token)); + const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId)); + + const [selectedFiles, setSelectedFiles] = useState([]); + const [session, setSession] = useState(null); + const [sendPhase, setSendPhase] = useState('idle'); + const [sendProgress, setSendProgress] = useState(0); + const [sendError, setSendError] = useState(''); + const [copied, setCopied] = useState(false); + + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + const copiedTimerRef = useRef(null); + const pollTimerRef = useRef(null); + const peerConnectionRef = useRef(null); + const dataChannelRef = useRef(null); + const cursorRef = useRef(0); + const bootstrapIdRef = useRef(0); + const totalBytesRef = useRef(0); + const sentBytesRef = useRef(0); + const sendingStartedRef = useRef(false); + const pendingRemoteCandidatesRef = useRef([]); + const manifestRef = useRef([]); + + useEffect(() => { + if (!folderInputRef.current) { + return; + } + + folderInputRef.current.setAttribute('webkitdirectory', ''); + folderInputRef.current.setAttribute('directory', ''); + }, []); + + useEffect(() => { + return () => { + cleanupCurrentTransfer(); + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!allowSend || sessionId) { + setActiveTab('receive'); + } + }, [allowSend, sessionId]); + + const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); + const shareLink = session + ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) + : ''; + const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : ''; + + function cleanupCurrentTransfer() { + if (pollTimerRef.current) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + + if (dataChannelRef.current) { + dataChannelRef.current.close(); + dataChannelRef.current = null; + } + + if (peerConnectionRef.current) { + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + + cursorRef.current = 0; + sendingStartedRef.current = false; + pendingRemoteCandidatesRef.current = []; + } + + function resetSenderState() { + cleanupCurrentTransfer(); + setSession(null); + setSelectedFiles([]); + setSendPhase('idle'); + setSendProgress(0); + setSendError(''); + } + + async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + } + + function ensureReadyState(nextFiles: File[]) { + setSelectedFiles(nextFiles); + + if (nextFiles.length === 0) { + resetSenderState(); + return; + } + + void bootstrapTransfer(nextFiles); + } + + function appendFiles(files: FileList | File[]) { + const nextFiles = [...selectedFiles, ...Array.from(files)]; + ensureReadyState(nextFiles); + } + + function handleFileSelect(event: React.ChangeEvent) { + if (event.target.files?.length) { + appendFiles(event.target.files); + } + event.target.value = ''; + } + + function handleDragOver(event: React.DragEvent) { + event.preventDefault(); + } + + function handleDrop(event: React.DragEvent) { + event.preventDefault(); + if (event.dataTransfer.files?.length) { + appendFiles(event.dataTransfer.files); + } + } + + function removeFile(indexToRemove: number) { + ensureReadyState(selectedFiles.filter((_, index) => index !== indexToRemove)); + } + + async function bootstrapTransfer(files: File[]) { + const bootstrapId = bootstrapIdRef.current + 1; + bootstrapIdRef.current = bootstrapId; + + cleanupCurrentTransfer(); + setSendError(''); + setSendPhase('creating'); + setSendProgress(0); + manifestRef.current = createTransferFileManifest(files); + totalBytesRef.current = 0; + sentBytesRef.current = 0; + + try { + const createdSession = await createTransferSession(files); + if (bootstrapIdRef.current !== bootstrapId) { + return; + } + + setSession(createdSession); + setSendPhase('waiting'); + await setupSenderPeer(createdSession, files, bootstrapId); + } catch (error) { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } + setSendPhase('error'); + setSendError(error instanceof Error ? error.message : '快传会话创建失败'); + } + } + + async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { + const connection = new RTCPeerConnection({ + iceServers: DEFAULT_TRANSFER_ICE_SERVERS, + }); + const channel = connection.createDataChannel('portal-transfer', { + ordered: true, + }); + + peerConnectionRef.current = connection; + dataChannelRef.current = channel; + channel.binaryType = 'arraybuffer'; + + connection.onicecandidate = (event) => { + if (!event.candidate) { + return; + } + + void postTransferSignal( + createdSession.sessionId, + 'sender', + 'ice-candidate', + JSON.stringify(event.candidate.toJSON()), + ); + }; + + connection.onconnectionstatechange = () => { + if (connection.connectionState === 'connected') { + setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting')); + } + + if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') { + setSendPhase('error'); + setSendError('浏览器直连失败,请重新生成分享链接再试一次。'); + } + }; + + channel.onopen = () => { + channel.send(createTransferFileManifestMessage(manifestRef.current)); + }; + + channel.onmessage = (event) => { + if (typeof event.data !== 'string') { + return; + } + + const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data); + if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) { + return; + } + + if (sendingStartedRef.current) { + return; + } + + const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id)); + if (requestedFiles.length === 0) { + return; + } + + sendingStartedRef.current = true; + totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0); + sentBytesRef.current = 0; + setSendProgress(0); + void sendSelectedFiles(channel, files, requestedFiles, bootstrapId); + }; + + channel.onerror = () => { + setSendPhase('error'); + setSendError('数据通道建立失败,请重新开始本次快传。'); + }; + + startSenderPolling(createdSession.sessionId, connection, bootstrapId); + + const offer = await connection.createOffer(); + await connection.setLocalDescription(offer); + await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer)); + } + + function startSenderPolling(sessionId: string, connection: RTCPeerConnection, bootstrapId: number) { + let polling = false; + + pollTimerRef.current = window.setInterval(() => { + if (polling || bootstrapIdRef.current !== bootstrapId) { + return; + } + + polling = true; + + void pollTransferSignals(sessionId, 'sender', cursorRef.current) + .then(async (response) => { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } + + cursorRef.current = response.nextCursor; + + for (const item of response.items) { + if (item.type === 'peer-joined') { + setSendPhase((current) => (current === 'waiting' ? 'connecting' : current)); + continue; + } + + if (item.type === 'answer' && !connection.currentRemoteDescription) { + const answer = parseJsonPayload(item.payload); + if (answer) { + await connection.setRemoteDescription(answer); + pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates( + connection, + pendingRemoteCandidatesRef.current, + ); + } + continue; + } + + if (item.type === 'ice-candidate') { + const candidate = parseJsonPayload(item.payload); + if (candidate) { + pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate( + connection, + pendingRemoteCandidatesRef.current, + candidate, + ); + } + } + } + }) + .catch((error) => { + if (bootstrapIdRef.current !== bootstrapId) { + return; + } + setSendPhase('error'); + setSendError(error instanceof Error ? error.message : '轮询连接状态失败'); + }) + .finally(() => { + polling = false; + }); + }, SIGNAL_POLL_INTERVAL_MS); + } + + async function sendSelectedFiles( + channel: RTCDataChannel, + files: File[], + requestedFiles: TransferFileDescriptor[], + bootstrapId: number, + ) { + setSendPhase('transferring'); + const filesById = new Map(files.map((file) => [createTransferFileId(file), file])); + + for (const descriptor of requestedFiles) { + if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') { + return; + } + + const file = filesById.get(descriptor.id); + if (!file) { + continue; + } + + channel.send(createTransferFileMetaMessage(descriptor)); + + for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) { + if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') { + return; + } + + const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer(); + await waitForTransferChannelDrain(channel); + channel.send(chunk); + sentBytesRef.current += chunk.byteLength; + + if (totalBytesRef.current > 0) { + setSendProgress(Math.min( + 99, + Math.round((sentBytesRef.current / totalBytesRef.current) * 100), + )); + } + } + + channel.send(createTransferFileCompleteMessage(descriptor.id)); + } + + channel.send(createTransferCompleteMessage()); + setSendProgress(100); + setSendPhase('completed'); + } + + return ( +
+
+
+
+ +
+

P2P 快传

+

二维码负责把对方带到网页,真正的文件内容在两个浏览器之间通过 P2P 直连传输。

+
+ +
+ {allowSend ? ( +
+ + +
+ ) : null} + +
+ + {activeTab === 'send' ? ( + + {selectedFiles.length === 0 ? ( +
+
+ +
+

拖拽文件或文件夹到此处

+

+ 选中文件后会自动创建一条公开接收链接,扫码打开网页就能在浏览器之间发起 P2P 下载。 +

+
+ + +
+
+ ) : ( +
+
+ + +

取件码

+
+ {session?.pickupCode ?? '......'} +
+ + {qrImageUrl ? ( +
+ 快传分享二维码 +
+ ) : null} + +
+
+ + 分享链接 +
+
{shareLink || '会话创建中...'}
+
+ + +
+ +
+
+
+

待发送文件

+ {selectedFiles.length} 个项目 • {formatTransferSize(totalSize)} +
+ +
+ +
+ {selectedFiles.map((file, index) => ( +
+
+ +
+
+

{file.name}

+

{formatTransferSize(file.size)}

+
+ +
+ ))} +
+ + + +
+ {sendPhase === 'completed' ? ( + + ) : ( + + )} +
+

+ {getPhaseMessage(sendPhase, sendError)} +

+

+ 发送进度 {sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}` : ''} +

+
+
+
+
+ )} + + + +
+ ) : ( + + + + )} +
+
+
+ +
+
+
+ +
+
+

扫码直达网页

+

二维码不承载文件本身,只负责把另一台设备带到公开接收页。

+
+
+
+
+ +
+
+

浏览器 P2P 传输

+

网页之间通过 WebRTC DataChannel 交换文件字节,后端只做信令和会话协调。

+
+
+
+
+ +
+
+

面向一次性分享

+

更适合把压缩包、截图和临时资料从当前浏览器快速交给另一台设备。

+
+
+
+
+
+ ); +} diff --git a/front/src/pages/TransferReceive.tsx b/front/src/pages/TransferReceive.tsx new file mode 100644 index 0000000..906600d --- /dev/null +++ b/front/src/pages/TransferReceive.tsx @@ -0,0 +1,879 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Archive, + CheckCircle, + CheckSquare, + DownloadCloud, + File as FileIcon, + Loader2, + RefreshCcw, + Shield, + Square, +} from 'lucide-react'; +import { useSearchParams } 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 { Input } from '@/src/components/ui/input'; +import { buildTransferArchiveFileName, createTransferZipArchive } from '@/src/lib/transfer-archive'; +import { resolveNetdiskSaveDirectory, saveFileToNetdisk } from '@/src/lib/netdisk-upload'; +import { + createTransferReceiveRequestMessage, + parseTransferControlMessage, + SIGNAL_POLL_INTERVAL_MS, + toTransferChunk, + type TransferFileDescriptor, +} from '@/src/lib/transfer-protocol'; +import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; +import { DEFAULT_TRANSFER_ICE_SERVERS, joinTransferSession, lookupTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer'; +import type { TransferSessionResponse } from '@/src/lib/types'; + +import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state'; + +type ReceivePhase = 'idle' | 'joining' | 'waiting' | 'connecting' | 'receiving' | 'completed' | 'error'; + +interface DownloadableFile extends TransferFileDescriptor { + progress: number; + selected: boolean; + requested: boolean; + downloadUrl?: string; + savedToNetdisk?: boolean; +} + +interface IncomingTransferFile extends TransferFileDescriptor { + chunks: Uint8Array[]; + receivedBytes: number; +} + +function parseJsonPayload(payload: string): T | null { + try { + return JSON.parse(payload) as T; + } catch { + return null; + } +} + +interface TransferReceiveProps { + embedded?: boolean; +} + +export default function TransferReceive({ embedded = false }: TransferReceiveProps) { + const { session: authSession } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + const [receiveCode, setReceiveCode] = useState(searchParams.get('code') ?? ''); + const [transferSession, setTransferSession] = useState(null); + const [files, setFiles] = useState([]); + const [phase, setPhase] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [overallProgress, setOverallProgress] = useState(0); + const [lookupBusy, setLookupBusy] = useState(false); + const [requestSubmitted, setRequestSubmitted] = useState(false); + const [archiveRequested, setArchiveRequested] = useState(false); + const [archiveName, setArchiveName] = useState(buildTransferArchiveFileName('快传文件')); + const [archiveUrl, setArchiveUrl] = useState(null); + const [savingFileId, setSavingFileId] = useState(null); + const [saveMessage, setSaveMessage] = useState(''); + const [savePathPickerFileId, setSavePathPickerFileId] = useState(null); + const [saveRootPath, setSaveRootPath] = useState('/下载'); + + const peerConnectionRef = useRef(null); + const dataChannelRef = useRef(null); + const pollTimerRef = useRef(null); + const cursorRef = useRef(0); + const lifecycleIdRef = useRef(0); + const currentFileIdRef = useRef(null); + const totalBytesRef = useRef(0); + const receivedBytesRef = useRef(0); + const downloadUrlsRef = useRef([]); + const requestedFileIdsRef = useRef([]); + const pendingRemoteCandidatesRef = useRef([]); + const archiveBuiltRef = useRef(false); + const completedFilesRef = useRef(new Map()); + const incomingFilesRef = useRef(new Map()); + + useEffect(() => { + return () => { + cleanupReceiver(); + }; + }, []); + + useEffect(() => { + const sessionId = searchParams.get('session'); + if (!sessionId) { + setTransferSession(null); + setFiles([]); + setPhase('idle'); + setOverallProgress(0); + setRequestSubmitted(false); + setArchiveRequested(false); + setArchiveUrl(null); + return; + } + + void startReceivingSession(sessionId); + }, [searchParams]); + + function cleanupReceiver() { + if (pollTimerRef.current) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + + if (dataChannelRef.current) { + dataChannelRef.current.close(); + dataChannelRef.current = null; + } + + if (peerConnectionRef.current) { + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + + for (const url of downloadUrlsRef.current) { + URL.revokeObjectURL(url); + } + downloadUrlsRef.current = []; + completedFilesRef.current.clear(); + incomingFilesRef.current.clear(); + currentFileIdRef.current = null; + cursorRef.current = 0; + receivedBytesRef.current = 0; + totalBytesRef.current = 0; + requestedFileIdsRef.current = []; + pendingRemoteCandidatesRef.current = []; + archiveBuiltRef.current = false; + } + + async function startReceivingSession(sessionId: string) { + const lifecycleId = lifecycleIdRef.current + 1; + lifecycleIdRef.current = lifecycleId; + + cleanupReceiver(); + setPhase('joining'); + setErrorMessage(''); + setFiles([]); + setOverallProgress(0); + setRequestSubmitted(false); + setArchiveRequested(false); + setArchiveName(buildTransferArchiveFileName('快传文件')); + setArchiveUrl(null); + setSavingFileId(null); + setSaveMessage(''); + + try { + const joinedSession = await joinTransferSession(sessionId); + if (lifecycleIdRef.current !== lifecycleId) { + return; + } + + setTransferSession(joinedSession); + setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`)); + + const connection = new RTCPeerConnection({ + iceServers: DEFAULT_TRANSFER_ICE_SERVERS, + }); + peerConnectionRef.current = connection; + + connection.onicecandidate = (event) => { + if (!event.candidate) { + return; + } + + void postTransferSignal( + joinedSession.sessionId, + 'receiver', + 'ice-candidate', + JSON.stringify(event.candidate.toJSON()), + ); + }; + + connection.onconnectionstatechange = () => { + if (connection.connectionState === 'connected') { + setPhase((current) => (current === 'completed' ? current : 'connecting')); + } + + if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') { + setPhase('error'); + setErrorMessage('浏览器之间的直连失败,请重新打开分享链接。'); + } + }; + + connection.ondatachannel = (event) => { + const channel = event.channel; + dataChannelRef.current = channel; + channel.binaryType = 'arraybuffer'; + channel.onopen = () => { + setPhase((current) => (current === 'completed' ? current : 'connecting')); + }; + channel.onmessage = (messageEvent) => { + void handleIncomingMessage(messageEvent.data); + }; + }; + + startReceiverPolling(joinedSession.sessionId, connection, lifecycleId); + setPhase('waiting'); + } catch (error) { + if (lifecycleIdRef.current !== lifecycleId) { + return; + } + + setPhase('error'); + setErrorMessage(error instanceof Error ? error.message : '快传会话打开失败'); + } + } + + function startReceiverPolling(sessionId: string, connection: RTCPeerConnection, lifecycleId: number) { + let polling = false; + + pollTimerRef.current = window.setInterval(() => { + if (polling || lifecycleIdRef.current !== lifecycleId) { + return; + } + + polling = true; + + void pollTransferSignals(sessionId, 'receiver', cursorRef.current) + .then(async (response) => { + if (lifecycleIdRef.current !== lifecycleId) { + return; + } + + cursorRef.current = response.nextCursor; + + for (const item of response.items) { + if (item.type === 'offer') { + const offer = parseJsonPayload(item.payload); + if (!offer) { + continue; + } + + setPhase('connecting'); + await connection.setRemoteDescription(offer); + pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates( + connection, + pendingRemoteCandidatesRef.current, + ); + const answer = await connection.createAnswer(); + await connection.setLocalDescription(answer); + await postTransferSignal(sessionId, 'receiver', 'answer', JSON.stringify(answer)); + continue; + } + + if (item.type === 'ice-candidate') { + const candidate = parseJsonPayload(item.payload); + if (candidate) { + pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate( + connection, + pendingRemoteCandidatesRef.current, + candidate, + ); + } + } + } + }) + .catch((error) => { + if (lifecycleIdRef.current !== lifecycleId) { + return; + } + + setPhase('error'); + setErrorMessage(error instanceof Error ? error.message : '轮询传输信令失败'); + }) + .finally(() => { + polling = false; + }); + }, SIGNAL_POLL_INTERVAL_MS); + } + + async function finalizeArchiveDownload() { + if (!archiveRequested || archiveBuiltRef.current || requestedFileIdsRef.current.length === 0) { + return; + } + + const archiveEntries = requestedFileIdsRef.current.map((fileId) => completedFilesRef.current.get(fileId)).filter(Boolean); + if (archiveEntries.length !== requestedFileIdsRef.current.length) { + return; + } + + const archive = await createTransferZipArchive( + archiveEntries.map((entry) => ({ + name: entry.name, + relativePath: entry.relativePath, + data: entry.blob, + })), + ); + + const nextArchiveUrl = URL.createObjectURL(archive); + downloadUrlsRef.current.push(nextArchiveUrl); + archiveBuiltRef.current = true; + setArchiveUrl(nextArchiveUrl); + } + + async function handleIncomingMessage(data: string | ArrayBuffer | Blob) { + if (typeof data === 'string') { + const message = parseTransferControlMessage(data); + + if (!message) { + return; + } + + if (message.type === 'manifest') { + setFiles(message.files.map((file) => ({ + ...file, + progress: 0, + selected: true, + requested: false, + savedToNetdisk: false, + }))); + setPhase((current) => (current === 'receiving' || current === 'completed' ? current : 'waiting')); + return; + } + + if (message.type === 'file-meta') { + currentFileIdRef.current = message.id; + incomingFilesRef.current.set(message.id, { + ...message, + chunks: [], + receivedBytes: 0, + }); + setFiles((current) => + current.map((file) => + file.id === message.id + ? { + ...file, + requested: true, + progress: 0, + } + : file, + ), + ); + return; + } + + if (message.type === 'file-complete' && message.id) { + finalizeDownloadableFile(message.id); + currentFileIdRef.current = null; + await finalizeArchiveDownload(); + return; + } + + if (message.type === 'transfer-complete') { + await finalizeArchiveDownload(); + setOverallProgress(100); + setPhase('completed'); + } + + return; + } + + const activeFileId = currentFileIdRef.current; + if (!activeFileId) { + return; + } + + const targetFile = incomingFilesRef.current.get(activeFileId); + if (!targetFile) { + return; + } + + const chunk = await toTransferChunk(data); + targetFile.chunks.push(chunk); + targetFile.receivedBytes += chunk.byteLength; + receivedBytesRef.current += chunk.byteLength; + + setPhase('receiving'); + if (totalBytesRef.current > 0) { + setOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100))); + } + + setFiles((current) => + current.map((file) => + file.id === activeFileId + ? { + ...file, + progress: Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100)), + } + : file, + ), + ); + } + + function finalizeDownloadableFile(fileId: string) { + const targetFile = incomingFilesRef.current.get(fileId); + if (!targetFile) { + return; + } + + const blob = new Blob(targetFile.chunks, { + type: targetFile.contentType, + }); + const downloadUrl = URL.createObjectURL(blob); + downloadUrlsRef.current.push(downloadUrl); + completedFilesRef.current.set(fileId, { + name: targetFile.name, + relativePath: targetFile.relativePath, + blob, + contentType: targetFile.contentType, + }); + + setFiles((current) => + current.map((file) => + file.id === fileId + ? { + ...file, + progress: 100, + requested: true, + downloadUrl, + savedToNetdisk: false, + } + : file, + ), + ); + } + + async function saveCompletedFile(fileId: string, rootPath: string) { + const completedFile = completedFilesRef.current.get(fileId); + if (!completedFile) { + return; + } + + setSavingFileId(fileId); + setSaveMessage(''); + + try { + const netdiskFile = new File([completedFile.blob], completedFile.name, { + type: completedFile.contentType || completedFile.blob.type || 'application/octet-stream', + }); + const targetPath = resolveNetdiskSaveDirectory(completedFile.relativePath, rootPath); + const savedFile = await saveFileToNetdisk(netdiskFile, targetPath); + setFiles((current) => + current.map((file) => + file.id === fileId + ? { + ...file, + savedToNetdisk: true, + } + : file, + ), + ); + setSaveMessage(`${savedFile.filename} 已存入网盘 ${savedFile.path}`); + } catch (requestError) { + setErrorMessage(requestError instanceof Error ? requestError.message : '存入网盘失败'); + throw requestError; + } finally { + setSavingFileId(null); + } + } + + function toggleFileSelection(fileId: string) { + if (requestSubmitted) { + return; + } + + setFiles((current) => + current.map((file) => + file.id === fileId + ? { + ...file, + selected: !file.selected, + } + : file, + ), + ); + } + + function toggleSelectAll(nextSelected: boolean) { + if (requestSubmitted) { + return; + } + + setFiles((current) => + current.map((file) => ({ + ...file, + selected: nextSelected, + })), + ); + } + + async function submitReceiveRequest(archive: boolean, fileIds?: string[]) { + const channel = dataChannelRef.current; + if (!channel || channel.readyState !== 'open') { + setPhase('error'); + setErrorMessage('P2P 通道尚未准备好,请稍后再试。'); + return; + } + + const requestedIds = fileIds ?? files.filter((file) => file.selected).map((file) => file.id); + if (requestedIds.length === 0) { + setErrorMessage('请先选择至少一个文件。'); + return; + } + + const requestedSet = new Set(requestedIds); + const requestedBytes = files + .filter((file) => requestedSet.has(file.id)) + .reduce((sum, file) => sum + file.size, 0); + + requestedFileIdsRef.current = requestedIds; + totalBytesRef.current = requestedBytes; + receivedBytesRef.current = 0; + archiveBuiltRef.current = false; + setOverallProgress(0); + setArchiveRequested(archive); + setArchiveUrl(null); + setRequestSubmitted(true); + setErrorMessage(''); + + setFiles((current) => + current.map((file) => ({ + ...file, + selected: requestedSet.has(file.id), + requested: requestedSet.has(file.id), + progress: requestedSet.has(file.id) ? 0 : file.progress, + })), + ); + + channel.send(createTransferReceiveRequestMessage(requestedIds, archive)); + setPhase('waiting'); + } + + async function handleLookupByCode() { + setLookupBusy(true); + setErrorMessage(''); + + try { + const result = await lookupTransferSession(receiveCode); + setSearchParams({ + session: result.sessionId, + }); + } catch (error) { + setPhase('error'); + setErrorMessage(error instanceof Error ? error.message : '取件码无效或会话已过期'); + } finally { + setLookupBusy(false); + } + } + + const sessionId = searchParams.get('session'); + const selectedFiles = files.filter((file) => file.selected); + const requestedFiles = files.filter((file) => file.requested); + const selectedSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); + const canZipAllFiles = canArchiveTransferSelection(files); + const hasSelectableFiles = selectedFiles.length > 0; + const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles); + + const panelContent = ( + <> + {!embedded ? ( +
+
+ +
+

网页接收页

+

你现在打开的是公开接收链接,先选文件,再通过浏览器 P2P 通道接收并下载。

+
+ ) : null} + +
+
+ {!sessionId ? ( +
+
+ +
+

输入取件码打开接收页

+
+ setReceiveCode(sanitizeReceiveCode(event.target.value))} + placeholder="例如: 849201" + className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white" + /> +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ ) : ( +
+
+
+
+

当前会话

+

{transferSession?.pickupCode ?? '连接中...'}

+
+ +
+ +
+
+ {phase === 'completed' ? ( + + ) : ( + + )} +
+

+ {phase === 'joining' && '正在加入快传会话...'} + {phase === 'waiting' && (files.length === 0 + ? 'P2P 已连通,正在同步文件清单...' + : requestSubmitted + ? '已提交接收请求,等待发送端开始推送...' + : '文件清单已同步,请勾选要接收的文件。')} + {phase === 'connecting' && 'P2P 通道协商中...'} + {phase === 'receiving' && '文件正在接收...'} + {phase === 'completed' && (archiveUrl ? '接收完成,ZIP 已准备好下载' : '接收完成,下面可以下载文件')} + {phase === 'error' && '接收失败'} +

+

+ {errorMessage || `总进度 ${overallProgress}%`} +

+
+
+ +
+
+
+
+ + {archiveUrl ? ( +
+
+
+ +
+
+

全部文件 ZIP 已生成

+

{archiveName}

+
+ + 下载 ZIP + +
+
+ ) : null} + {saveMessage ? ( +
+ {saveMessage} +
+ ) : null} +
+ +
+
+
+

可接收文件

+

+ {requestSubmitted + ? `已请求 ${requestedFiles.length} 项` + : `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`} +

+
+ {!requestSubmitted && files.length > 0 ? ( +
+ + +
+ ) : null} +
+ + {!requestSubmitted && files.length > 0 ? ( +
+ + {canZipAllFiles ? ( + + ) : null} +
+ ) : null} + +
+ {files.length === 0 ? ( +
+ 连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。 +
+ ) : ( + files.map((file) => ( +
+
+ {!requestSubmitted ? ( + + ) : null} + +
+ +
+ +
+

{file.name}

+

+ {file.relativePath !== file.name ? `${file.relativePath} · ` : ''} + {formatTransferSize(file.size)} +

+
+ + {requestSubmitted ? ( + file.requested ? ( + file.downloadUrl ? ( +
+ + 下载 + + {authSession?.token ? ( + + ) : null} +
+ ) : ( + {file.progress}% + ) + ) : ( + 未接收 + ) + ) : null} +
+ + {requestSubmitted && file.requested ? ( +
+
+
+ ) : null} +
+ )) + )} +
+
+
+ )} +
+
+ + {!embedded ? ( +
+
+
+ +
+

后端只做信令

+

当前页面通过后端交换 offer、answer 和 ICE candidate,但文件字节不走服务器中转。

+
+
+
+ +
+

先选文件,再接收下载

+

文件清单会先同步到页面,多文件可以勾选接收,整包接收时会在浏览器内直接生成 ZIP。

+
+
+ ) : null} + + { + const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null; + return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path; + }} + onClose={() => setSavePathPickerFileId(null)} + onConfirm={async (path) => { + if (!savePathPickerFileId) { + return; + } + setSaveRootPath(path); + await saveCompletedFile(savePathPickerFileId, path); + setSavePathPickerFileId(null); + }} + /> + + ); + + if (embedded) { + return panelContent; + } + + return ( +
+
+ {panelContent} +
+
+ ); +} diff --git a/front/src/pages/transfer-state.test.ts b/front/src/pages/transfer-state.test.ts new file mode 100644 index 0000000..664feec --- /dev/null +++ b/front/src/pages/transfer-state.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildTransferShareUrl } from '../lib/transfer-links'; +import { + canArchiveTransferSelection, + buildQrImageUrl, + canSendTransferFiles, + createMockTransferCode, + formatTransferSize, + resolveInitialTransferTab, + sanitizeReceiveCode, +} from './transfer-state'; + +test('createMockTransferCode returns a six digit numeric code', () => { + const code = createMockTransferCode(); + + assert.match(code, /^\d{6}$/); +}); + +test('sanitizeReceiveCode keeps only the first six digits', () => { + assert.equal(sanitizeReceiveCode(' 98a76-54321 '), '987654'); +}); + +test('formatTransferSize uses readable units', () => { + assert.equal(formatTransferSize(0), '0 B'); + assert.equal(formatTransferSize(2048), '2 KB'); + assert.equal(formatTransferSize(2.5 * 1024 * 1024), '2.5 MB'); +}); + +test('buildTransferShareUrl builds a browser-router receive url', () => { + assert.equal( + buildTransferShareUrl('https://yoyuzh.xyz', '849201', 'browser'), + 'https://yoyuzh.xyz/transfer?session=849201', + ); +}); + +test('buildTransferShareUrl builds a hash-router receive url', () => { + assert.equal( + buildTransferShareUrl('https://yoyuzh.xyz/', '849201', 'hash'), + 'https://yoyuzh.xyz/#/transfer?session=849201', + ); +}); + +test('buildQrImageUrl encodes the share url as a QR image endpoint', () => { + assert.equal( + buildQrImageUrl(buildTransferShareUrl('https://yoyuzh.xyz', '849201', 'browser')), + 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=https%3A%2F%2Fyoyuzh.xyz%2Ftransfer%3Fsession%3D849201', + ); +}); + +test('resolveInitialTransferTab prefers receive mode for public visitors and shared sessions', () => { + assert.equal(resolveInitialTransferTab(false, null), 'receive'); + assert.equal(resolveInitialTransferTab(true, '849201'), 'receive'); + assert.equal(resolveInitialTransferTab(true, null), 'send'); +}); + +test('canSendTransferFiles requires an authenticated session', () => { + assert.equal(canSendTransferFiles(true), true); + assert.equal(canSendTransferFiles(false), false); +}); + +test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => { + assert.equal(canArchiveTransferSelection([ + { + relativePath: 'report.pdf', + }, + ]), false); + + assert.equal(canArchiveTransferSelection([ + { + relativePath: '课程资料/report.pdf', + }, + ]), true); + + assert.equal(canArchiveTransferSelection([ + { + relativePath: 'report.pdf', + }, + { + relativePath: 'notes.txt', + }, + ]), true); +}); diff --git a/front/src/pages/transfer-state.ts b/front/src/pages/transfer-state.ts new file mode 100644 index 0000000..a8b5ce8 --- /dev/null +++ b/front/src/pages/transfer-state.ts @@ -0,0 +1,52 @@ +import type { TransferFileDescriptor } from '../lib/transfer-protocol'; + +export type TransferTab = 'send' | 'receive'; + +export function createMockTransferCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +export function sanitizeReceiveCode(value: string) { + return value.replace(/\D/g, '').slice(0, 6); +} + +export function formatTransferSize(bytes: number) { + if (bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + const displayValue = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1); + return `${displayValue.replace(/\.0$/, '')} ${units[unitIndex]}`; +} + +export function buildQrImageUrl(shareUrl: string) { + return `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(shareUrl)}`; +} + +export function canSendTransferFiles(isAuthenticated: boolean) { + return isAuthenticated; +} + +export function resolveInitialTransferTab( + isAuthenticated: boolean, + sessionId: string | null, +): TransferTab { + if (!canSendTransferFiles(isAuthenticated) || sessionId) { + return 'receive'; + } + + return 'send'; +} + +export function canArchiveTransferSelection(files: Pick[]) { + return files.length > 1 || files.some((file) => file.relativePath.includes('/')); +} diff --git a/scripts/local-smoke.ps1 b/scripts/local-smoke.ps1 index 0576ec6..86c199b 100644 --- a/scripts/local-smoke.ps1 +++ b/scripts/local-smoke.ps1 @@ -79,11 +79,6 @@ try { throw '文件列表为空' } - $schedule = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/api/cqu/schedule?semester=2025-2026-1&studentId=20230001' -Headers $headers -Method Get - if ($schedule.data.Count -lt 1) { - throw '课表接口为空' - } - $frontend = Start-Process ` -FilePath 'cmd.exe' ` -ArgumentList '/c', 'npm run dev -- --host 127.0.0.1 --port 4173' ` @@ -113,7 +108,6 @@ try { Write-Output "BACKEND_OK username=$username" Write-Output "FILES_OK count=$($files.data.items.Count)" - Write-Output "SCHEDULE_OK count=$($schedule.data.Count)" Write-Output 'FRONTEND_OK url=http://127.0.0.1:4173' } finally { diff --git a/scripts/oss-deploy-lib.mjs b/scripts/oss-deploy-lib.mjs index c9cf1b3..69475b4 100644 --- a/scripts/oss-deploy-lib.mjs +++ b/scripts/oss-deploy-lib.mjs @@ -18,15 +18,15 @@ const CONTENT_TYPES = new Map([ ]); const FRONTEND_SPA_ALIASES = [ + 't', 'overview', 'files', - 'school', + 'transfer', 'games', 'login', 'admin', 'admin/users', 'admin/files', - 'admin/schoolSnapshots', ]; export function normalizeEndpoint(endpoint) { diff --git a/scripts/oss-deploy-lib.test.mjs b/scripts/oss-deploy-lib.test.mjs index 9b668ce..e683bc7 100644 --- a/scripts/oss-deploy-lib.test.mjs +++ b/scripts/oss-deploy-lib.test.mjs @@ -36,9 +36,10 @@ test('getContentType resolves common frontend asset types', () => { test('frontend spa aliases are uploaded as html entry points', () => { const aliases = getFrontendSpaAliasKeys(); + assert.ok(aliases.includes('t/index.html')); assert.ok(aliases.includes('overview')); + assert.ok(aliases.includes('transfer/index.html')); assert.ok(aliases.includes('admin/users')); - assert.ok(aliases.includes('admin/schoolSnapshots/index.html')); assert.equal(getFrontendSpaAliasContentType(), 'text/html; charset=utf-8'); });