diff --git a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java index d699cb0..11a8889 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java +++ b/backend/src/main/java/com/yoyuzh/transfer/OfflineTransferSessionRepository.java @@ -36,6 +36,16 @@ public interface OfflineTransferSessionRepository extends JpaRepository findAllExpiredWithFiles(@Param("now") Instant now); + @Query(""" + select distinct session + from OfflineTransferSession session + left join fetch session.files + where session.senderUserId = :senderUserId and session.expiresAt >= :now + order by session.expiresAt desc + """) + List findActiveWithFilesBySenderUserId(@Param("senderUserId") Long senderUserId, + @Param("now") Instant now); + @Query(""" select coalesce(sum(file.size), 0) from OfflineTransferFile file diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java index 12a0f23..8768c8b 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferController.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferController.java @@ -35,21 +35,31 @@ public class TransferController { @PostMapping("/sessions") public ApiResponse createSession(@AuthenticationPrincipal UserDetails userDetails, @Valid @RequestBody CreateTransferSessionRequest request) { - requireAuthenticatedUser(userDetails); - User sender = userDetailsService.loadDomainUser(userDetails.getUsername()); + User sender = loadAuthenticatedUser(userDetails); return ApiResponse.success(transferService.createSession(sender, request)); } @Operation(summary = "通过取件码查找快传会话") @GetMapping("/sessions/lookup") - public ApiResponse lookupSession(@RequestParam String pickupCode) { - return ApiResponse.success(transferService.lookupSession(pickupCode)); + public ApiResponse lookupSession(@AuthenticationPrincipal UserDetails userDetails, + @RequestParam String pickupCode) { + return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode)); } @Operation(summary = "加入快传会话") @PostMapping("/sessions/{sessionId}/join") - public ApiResponse joinSession(@PathVariable String sessionId) { - return ApiResponse.success(transferService.joinSession(sessionId)); + public ApiResponse joinSession(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId) { + return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId)); + } + + @Operation(summary = "查看当前用户的离线快传列表") + @GetMapping("/sessions/offline/mine") + public ApiResponse> listOfflineSessions(@AuthenticationPrincipal UserDetails userDetails) { + requireAuthenticatedUser(userDetails); + return ApiResponse.success(transferService.listOfflineSessions( + userDetailsService.loadDomainUser(userDetails.getUsername()) + )); } @Operation(summary = "上传离线快传文件") @@ -70,9 +80,10 @@ public class TransferController { @Operation(summary = "下载离线快传文件") @GetMapping("/sessions/{sessionId}/files/{fileId}/download") - public ResponseEntity downloadOfflineFile(@PathVariable String sessionId, + public ResponseEntity downloadOfflineFile(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String sessionId, @PathVariable String fileId) { - return transferService.downloadOfflineFile(sessionId, fileId); + return transferService.downloadOfflineFile(userDetails != null, sessionId, fileId); } @Operation(summary = "把离线快传文件存入网盘") @@ -112,4 +123,11 @@ public class TransferController { throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户未登录"); } } + + private User loadAuthenticatedUser(UserDetails userDetails) { + if (userDetails == null) { + return null; + } + return userDetailsService.loadDomainUser(userDetails.getUsername()); + } } diff --git a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java index 9694a05..2a02d61 100644 --- a/backend/src/main/java/com/yoyuzh/transfer/TransferService.java +++ b/backend/src/main/java/com/yoyuzh/transfer/TransferService.java @@ -59,12 +59,15 @@ public class TransferService { pruneExpiredSessions(); adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum()); if (request.mode() == TransferMode.OFFLINE) { + if (sender == null) { + throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用"); + } return createOfflineSession(sender, request); } return createOnlineSession(request); } - public LookupTransferSessionResponse lookupSession(String pickupCode) { + public LookupTransferSessionResponse lookupSession(boolean authenticated, String pickupCode) { pruneExpiredSessions(); String normalizedPickupCode = normalizePickupCode(pickupCode); @@ -73,11 +76,14 @@ public class TransferService { return onlineSession.toLookupResponse(); } - OfflineTransferSession offlineSession = getRequiredOfflineReadySessionByPickupCode(normalizedPickupCode); + OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效")); + ensureAuthenticatedForOfflineTransfer(authenticated); + validateOfflineReadySession(offlineSession, "取件码不存在或已失效"); return toLookupResponse(offlineSession); } - public TransferSessionResponse joinSession(String sessionId) { + public TransferSessionResponse joinSession(boolean authenticated, String sessionId) { pruneExpiredSessions(); TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null); @@ -90,10 +96,20 @@ public class TransferService { return onlineSession.toSessionResponse(); } - OfflineTransferSession offlineSession = getRequiredOfflineReadySession(sessionId); + OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效")); + ensureAuthenticatedForOfflineTransfer(authenticated); + validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效"); return toSessionResponse(offlineSession); } + public List listOfflineSessions(User sender) { + pruneExpiredSessions(); + return offlineTransferSessionRepository.findActiveWithFilesBySenderUserId(sender.getId(), Instant.now()).stream() + .map(this::toSessionResponse) + .toList(); + } + @Transactional public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) { pruneExpiredSessions(); @@ -155,8 +171,9 @@ public class TransferService { return session.poll(TransferRole.from(role), Math.max(0, after)); } - public ResponseEntity downloadOfflineFile(String sessionId, String fileId) { + public ResponseEntity downloadOfflineFile(boolean authenticated, String sessionId, String fileId) { pruneExpiredSessions(); + ensureAuthenticatedForOfflineTransfer(authenticated); OfflineTransferSession session = getRequiredOfflineReadySession(sessionId); OfflineTransferFile file = getRequiredOfflineFile(session, fileId); ensureOfflineFileUploaded(file); @@ -314,24 +331,14 @@ public class TransferService { private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) { OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效")); - if (session.isExpired(Instant.now())) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"); - } - if (!session.isReady()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试"); - } + validateOfflineReadySession(session, "离线快传会话不存在或已失效"); return session; } private OfflineTransferSession getRequiredOfflineReadySessionByPickupCode(String pickupCode) { OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesByPickupCode(pickupCode) .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效")); - if (session.isExpired(Instant.now())) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"); - } - if (!session.isReady()) { - throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试"); - } + validateOfflineReadySession(session, "取件码不存在或已失效"); return session; } @@ -365,6 +372,21 @@ public class TransferService { return normalized; } + private void ensureAuthenticatedForOfflineTransfer(boolean authenticated) { + if (!authenticated) { + throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用"); + } + } + + private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) { + if (session.isExpired(Instant.now())) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage); + } + if (!session.isReady()) { + throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试"); + } + } + private String normalizeContentType(String contentType) { String normalized = Objects.requireNonNullElse(contentType, "").trim(); return normalized.isEmpty() ? "application/octet-stream" : normalized; diff --git a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java index babd4c8..13a57f5 100644 --- a/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/transfer/TransferControllerIntegrationTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -120,18 +121,70 @@ class TransferControllerIntegrationTest { } @Test - void shouldRejectAnonymousSessionCreationButAllowPublicJoinEndpoints() throws Exception { + void shouldAllowAnonymousOnlineSessionCreationButKeepOfflineRestricted() throws Exception { mockMvc.perform(post("/api/transfer/sessions") .contentType(MediaType.APPLICATION_JSON) .content(""" {"mode":"ONLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]} """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("ONLINE")) + .andExpect(jsonPath("$.data.sessionId").isNotEmpty()); + + mockMvc.perform(post("/api/transfer/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mode":"OFFLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]} + """)) .andExpect(status().isUnauthorized()); mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session")) .andExpect(status().isNotFound()); } + @Test + @WithMockUser(username = "alice") + void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception { + String response = mockMvc.perform(post("/api/transfer/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "mode": "OFFLINE", + "files": [ + {"name": "offline.txt", "relativePath": "资料/offline.txt", "size": 13, "contentType": "text/plain"} + ] + } + """)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + String sessionId = com.jayway.jsonpath.JsonPath.read(response, "$.data.sessionId"); + String pickupCode = com.jayway.jsonpath.JsonPath.read(response, "$.data.pickupCode"); + String fileId = com.jayway.jsonpath.JsonPath.read(response, "$.data.files[0].id"); + + MockMultipartFile offlineFile = new MockMultipartFile( + "file", + "offline.txt", + MediaType.TEXT_PLAIN_VALUE, + "hello offline".getBytes(StandardCharsets.UTF_8) + ); + + mockMvc.perform(multipart("/api/transfer/sessions/{sessionId}/files/{fileId}/content", sessionId, fileId) + .file(offlineFile)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode)) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId).with(anonymous())) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId).with(anonymous())) + .andExpect(status().isUnauthorized()); + } + @Test @WithMockUser(username = "alice") void shouldPersistOfflineTransfersForSevenDaysAndAllowRepeatedDownloads() throws Exception { @@ -191,4 +244,37 @@ class TransferControllerIntegrationTest { .andExpect(status().isOk()) .andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8))); } + + @Test + @WithMockUser(username = "alice") + void shouldListCurrentUsersOfflineTransferSessions() throws Exception { + User otherUser = new User(); + otherUser.setUsername("bob"); + otherUser.setEmail("bob@example.com"); + otherUser.setPhoneNumber("13900139000"); + otherUser.setPasswordHash("encoded-password"); + otherUser.setCreatedAt(LocalDateTime.now()); + userRepository.save(otherUser); + + mockMvc.perform(post("/api/transfer/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "mode": "OFFLINE", + "files": [ + {"name": "cover.png", "relativePath": "活动图/cover.png", "size": 2048, "contentType": "image/png"} + ] + } + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/transfer/sessions/offline/mine")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].mode").value("OFFLINE")) + .andExpect(jsonPath("$.data[0].pickupCode").isString()) + .andExpect(jsonPath("$.data[0].files[0].name").value("cover.png")) + .andExpect(jsonPath("$.data[0].files[0].relativePath").value("活动图/cover.png")) + .andExpect(jsonPath("$.data[0].files[0].uploaded").value(false)); + } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 8850719..76924dc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -217,7 +217,8 @@ 说明: -- 创建快传会话需要发送端登录 +- 在线快传会话允许未登录用户创建 +- 离线快传会话仍要求发送端登录 - 请求体必须区分 `mode` - `ONLINE`: 在线快传,15 分钟有效,只能被接收一次 - `OFFLINE`: 离线快传,7 天有效,文件会落到站点存储并可被重复接收 @@ -230,6 +231,7 @@ 说明: - 接收端通过 6 位取件码查找会话 +- 未登录用户只能查找在线快传 ### 4.3 加入会话 @@ -239,6 +241,7 @@ - 在线快传会占用一次性会话 - 离线快传返回可下载文件清单,不需要建立 P2P 通道 +- 未登录用户只能加入在线快传 ### 4.4 信令交换 @@ -252,7 +255,17 @@ - 实际文件通过浏览器 DataChannel 进行 P2P 传输 - 该组接口仅用于 `ONLINE` 模式 -### 4.5 上传离线快传文件 +### 4.5 查看我的离线快传记录 + +`GET /api/transfer/sessions/offline/mine` + +说明: + +- 需要登录 +- 返回当前用户未过期的离线快传会话列表 +- 每个会话包含取件码、有效期和文件清单,前端可据此重新展示二维码与分享链接 + +### 4.6 上传离线快传文件 `POST /api/transfer/sessions/{sessionId}/files/{fileId}/content` @@ -260,7 +273,7 @@ - 需要发送端登录 - 发送端把离线文件内容上传到站点存储 -- 线上环境会把离线文件落到 OSS +- 线上环境会把离线文件落到对象存储 ### 4.6 下载离线快传文件 @@ -268,7 +281,7 @@ 说明: -- 公开接口 +- 需要登录 - 离线文件在有效期内可以被重复下载 ### 4.7 存入网盘 diff --git a/docs/architecture.md b/docs/architecture.md index 3662fdc..e740456 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,6 +163,8 @@ - 多文件或文件夹可走 ZIP 下载 - 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话 - 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收 +- 登录页提供直达快传入口;匿名用户只允许创建在线快传并接收在线快传,离线快传相关操作仍要求登录 +- 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层 ### 3.4 管理台模块 @@ -229,7 +231,7 @@ ### 4.5 快传流程 -1. 发送端登录后创建快传会话 +1. 发送端可在登录后或未登录状态下创建在线快传会话 2. 若是在线模式,后端返回 `sessionId + pickupCode` 并保留 15 分钟的一次性会话 3. 接收端通过取件码或分享链接加入在线会话 4. 双方通过 `/api/transfer/.../signals` 交换 offer / answer / ice @@ -246,6 +248,12 @@ 6. 接收端可直接下载离线文件,也可登录后存入网盘 7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁 +补充说明: + +- 离线快传的创建、查找、加入和下载都要求登录 +- 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式 +- 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息 + ### 4.7 管理员改密流程 1. 管理台调用 `PUT /api/admin/users/{userId}/password` diff --git a/front/index.html b/front/index.html index 21dfe69..d85c3de 100644 --- a/front/index.html +++ b/front/index.html @@ -3,11 +3,10 @@ - My Google AI Studio App + 优立云盘
- diff --git a/front/src/lib/transfer.ts b/front/src/lib/transfer.ts index 1665e14..311b302 100644 --- a/front/src/lib/transfer.ts +++ b/front/src/lib/transfer.ts @@ -44,6 +44,10 @@ export function joinTransferSession(sessionId: string) { }); } +export function listMyOfflineTransferSessions() { + return apiRequest('/transfer/sessions/offline/mine'); +} + export function uploadOfflineTransferFile( sessionId: string, fileId: string, diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 0b36dcb..c01f65e 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'motion/react'; -import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react'; +import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone, Send } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; import { Button } from '@/src/components/ui/button'; @@ -15,6 +15,28 @@ import { buildRegisterPayload, validateRegisterForm } from './login-state'; const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true'; +function BlurRevealTitle({ text }: { text: string }) { + return ( + + {Array.from(text).map((char, index) => ( + + {char === ' ' ? '\u00A0' : char} + + ))} + + ); +} + export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -134,20 +156,27 @@ export default function Login() { >
- Access Portal + 优立云盘
-

YOYUZH.XYZ

-

- 个人网站 -
- 统一入口 -

+

YOULI CLOUD

+ +
+
+

+ +

+

- 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。 + 欢迎来到优立云盘。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。

)} @@ -234,6 +263,18 @@ export default function Login() { '进入系统' )} + +

+ 无需登录,仅支持在线发送和在线接收 +

+
+ ) : null} + {activeTab === 'send' ? ( -
- {(['ONLINE', 'OFFLINE'] as TransferMode[]).map((mode) => { - const summary = getTransferModeSummary(mode); - const active = transferMode === mode; + {availableTransferModes.length > 1 ? ( +
+ {availableTransferModes.map((mode) => { + const summary = getTransferModeSummary(mode); + const active = transferMode === mode; - return ( - - ); - })} -
+ return ( + + ); + })} +
+ ) : null} {selectedFiles.length === 0 ? (
)} + {isAuthenticated ? ( +
+
+
+

我的离线快传

+

+ 这里只保留未过期的离线快传记录,点击即可重新查看取件码和分享链接。 +

+
+ +
+ + {offlineHistoryLoading && offlineHistory.length === 0 ? ( +
+ 正在加载离线快传记录... +
+ ) : offlineHistoryError ? ( +
+ {offlineHistoryError} +
+ ) : offlineHistory.length === 0 ? ( +
+ 你还没有发过离线快传。 +
+ ) : ( +
+ {offlineHistory.map((historySession) => { + const ready = isOfflineSessionReady(historySession); + + return ( + + ); + })} +
+ )} +
+ ) : null} + @@ -791,6 +986,93 @@ export default function Transfer() {
+ + + {selectedOfflineSession ? ( + + + + +
+

取件码

+
+ {selectedOfflineSession.pickupCode} +
+
+ + {selectedOfflineSessionQrImageUrl ? ( +
+ 离线快传二维码 +
+ ) : null} + +
+
+ + 分享链接 +
+
+ {selectedOfflineSessionShareLink} +
+
+ + + +
+ {getOfflineTransferSessionLabel(selectedOfflineSession)} + {selectedOfflineSession.files.length} 个项目 + {getOfflineTransferSessionSize(selectedOfflineSession)} + + {isOfflineSessionReady(selectedOfflineSession) ? '文件已就绪,可重复接收' : '文件仍在上传中'} + +
+ +
+ + 有效期至 {new Date(selectedOfflineSession.expiresAt).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +
+
+
+ ) : null} +
); } diff --git a/front/src/pages/transfer-state.test.ts b/front/src/pages/transfer-state.test.ts index ef5c57d..963e2f1 100644 --- a/front/src/pages/transfer-state.test.ts +++ b/front/src/pages/transfer-state.test.ts @@ -3,6 +3,9 @@ import test from 'node:test'; import { buildTransferShareUrl } from '../lib/transfer-links'; import { + getAvailableTransferModes, + getOfflineTransferSessionLabel, + getOfflineTransferSessionSize, canArchiveTransferSelection, buildQrImageUrl, canSendTransferFiles, @@ -51,14 +54,19 @@ test('buildQrImageUrl encodes the share url as a QR image endpoint', () => { }); test('resolveInitialTransferTab prefers receive mode for public visitors and shared sessions', () => { - assert.equal(resolveInitialTransferTab(false, null), 'receive'); + assert.equal(resolveInitialTransferTab(false, null), 'send'); assert.equal(resolveInitialTransferTab(true, '849201'), 'receive'); assert.equal(resolveInitialTransferTab(true, null), 'send'); }); -test('canSendTransferFiles requires an authenticated session', () => { +test('canSendTransferFiles allows public online transfer entry', () => { assert.equal(canSendTransferFiles(true), true); - assert.equal(canSendTransferFiles(false), false); + assert.equal(canSendTransferFiles(false), true); +}); + +test('getAvailableTransferModes keeps offline mode behind login', () => { + assert.deepEqual(getAvailableTransferModes(false), ['ONLINE']); + assert.deepEqual(getAvailableTransferModes(true), ['ONLINE', 'OFFLINE']); }); test('getTransferModeSummary describes the offline seven-day retention rule', () => { @@ -68,6 +76,31 @@ test('getTransferModeSummary describes the offline seven-day retention rule', () }); }); +test('offline transfer history helpers summarize the session title and total size', () => { + const singleFileSession = { + sessionId: 'session-1', + pickupCode: '723325', + mode: 'OFFLINE' as const, + expiresAt: '2026-04-09T08:00:00Z', + files: [ + {name: 'cover.png', relativePath: '活动图/cover.png', size: 2048, contentType: 'image/png', uploaded: true}, + ], + }; + + const multiFileSession = { + ...singleFileSession, + sessionId: 'session-2', + files: [ + {name: 'cover.png', relativePath: '活动图/cover.png', size: 2048, contentType: 'image/png', uploaded: true}, + {name: 'notes.pdf', relativePath: '活动图/notes.pdf', size: 1024, contentType: 'application/pdf', uploaded: true}, + ], + }; + + assert.equal(getOfflineTransferSessionLabel(singleFileSession), 'cover.png'); + assert.equal(getOfflineTransferSessionLabel(multiFileSession), 'cover.png 等 2 项'); + assert.equal(getOfflineTransferSessionSize(multiFileSession), '3 KB'); +}); + test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => { assert.equal(canArchiveTransferSelection([ { diff --git a/front/src/pages/transfer-state.ts b/front/src/pages/transfer-state.ts index 35571ae..e99e001 100644 --- a/front/src/pages/transfer-state.ts +++ b/front/src/pages/transfer-state.ts @@ -1,4 +1,4 @@ -import type { TransferMode } from '../lib/types'; +import type { TransferMode, TransferSessionResponse } from '../lib/types'; import type { TransferFileDescriptor } from '../lib/transfer-protocol'; export type TransferTab = 'send' | 'receive'; @@ -34,7 +34,14 @@ export function buildQrImageUrl(shareUrl: string) { } export function canSendTransferFiles(isAuthenticated: boolean) { - return isAuthenticated; + return true; +} + +export function getAvailableTransferModes(isAuthenticated: boolean): TransferMode[] { + if (isAuthenticated) { + return ['ONLINE', 'OFFLINE']; + } + return ['ONLINE']; } export function getTransferModeSummary(mode: TransferMode) { @@ -51,11 +58,26 @@ export function getTransferModeSummary(mode: TransferMode) { }; } +export function getOfflineTransferSessionLabel(session: Pick) { + const firstFile = session.files[0]; + if (!firstFile) { + return '未命名离线快传'; + } + if (session.files.length === 1) { + return firstFile.name; + } + return `${firstFile.name} 等 ${session.files.length} 项`; +} + +export function getOfflineTransferSessionSize(session: Pick) { + return formatTransferSize(session.files.reduce((sum, file) => sum + file.size, 0)); +} + export function resolveInitialTransferTab( isAuthenticated: boolean, sessionId: string | null, ): TransferTab { - if (!canSendTransferFiles(isAuthenticated) || sessionId) { + if (sessionId) { return 'receive'; } diff --git a/memory.md b/memory.md index 4c99d34..ed09248 100644 --- a/memory.md +++ b/memory.md @@ -14,6 +14,8 @@ - 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧 - 游戏页已接入 `/race/`、`/t_race/`,带站内播放器、退出按钮和友情链接 - 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效 + - 2026-04-02 已放开未登录直达快传:登录页可直接进入快传,匿名用户可发在线快传和接收在线快传,但离线快传仍要求登录 + - 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接 - 根目录 README 已重写为中文公开版 GitHub 风格 - VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor - 进行中: @@ -36,6 +38,8 @@ | 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 | | 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 | | 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 | +| 匿名用户仅开放在线快传,不开放离线快传 | 允许登录页直接进入快传,同时避免匿名用户占用站点持久存储 | 匿名也开放离线快传: 会增加滥用风险和存储成本 | +| 已登录用户可以在快传页回看自己的离线快传记录 | 离线快传有效期长达 7 天,用户需要在不重新上传的情况下再次查看取件码和分享链接 | 只在刚创建成功时展示一次取件信息: 用户丢失取件码后无法自助找回 | ## 待解决问题 - [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误 @@ -73,5 +77,7 @@ - 密码策略: `backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java` - 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts` - 快传接收页: `front/src/pages/TransferReceive.tsx` + - 未登录快传权限: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java`、`backend/src/main/java/com/yoyuzh/transfer/TransferService.java` + - 离线快传历史与详情弹层: `front/src/pages/Transfer.tsx`、`front/src/pages/transfer-state.ts` - 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java` - 前端生产 API 基址: `front/.env.production`