Add offline transfer history and tighten anonymous access
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user