Add offline transfer history and tighten anonymous access

This commit is contained in:
yoyuzh
2026-04-02 16:30:04 +08:00
parent 97edc4cc32
commit 2cdda3c305
13 changed files with 626 additions and 82 deletions

View File

@@ -36,6 +36,16 @@ public interface OfflineTransferSessionRepository extends JpaRepository<OfflineT
""")
List<OfflineTransferSession> 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<OfflineTransferSession> findActiveWithFilesBySenderUserId(@Param("senderUserId") Long senderUserId,
@Param("now") Instant now);
@Query("""
select coalesce(sum(file.size), 0)
from OfflineTransferFile file

View File

@@ -35,21 +35,31 @@ public class TransferController {
@PostMapping("/sessions")
public ApiResponse<TransferSessionResponse> 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<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
return ApiResponse.success(transferService.lookupSession(pickupCode));
public ApiResponse<LookupTransferSessionResponse> lookupSession(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam String pickupCode) {
return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode));
}
@Operation(summary = "加入快传会话")
@PostMapping("/sessions/{sessionId}/join")
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
return ApiResponse.success(transferService.joinSession(sessionId));
public ApiResponse<TransferSessionResponse> joinSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId));
}
@Operation(summary = "查看当前用户的离线快传列表")
@GetMapping("/sessions/offline/mine")
public ApiResponse<java.util.List<TransferSessionResponse>> 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());
}
}

View File

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

View File

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