Fix Android WebView API access and mobile shell layout
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "portal_admin_daily_active_user",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_admin_daily_active_user_date_user", columnNames = {"metric_date", "user_id"})
|
||||
)
|
||||
public class AdminDailyActiveUserEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "metric_date", nullable = false)
|
||||
private LocalDate metricDate;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "username", nullable = false, length = 100)
|
||||
private String username;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDate getMetricDate() {
|
||||
return metricDate;
|
||||
}
|
||||
|
||||
public void setMetricDate(LocalDate metricDate) {
|
||||
this.metricDate = metricDate;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AdminDailyActiveUserRepository extends JpaRepository<AdminDailyActiveUserEntity, Long> {
|
||||
|
||||
List<AdminDailyActiveUserEntity> findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select entry from AdminDailyActiveUserEntity entry
|
||||
where entry.metricDate = :metricDate and entry.userId = :userId
|
||||
""")
|
||||
Optional<AdminDailyActiveUserEntity> findByMetricDateAndUserIdForUpdate(@Param("metricDate") LocalDate metricDate,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
@Modifying
|
||||
void deleteAllByMetricDateBefore(LocalDate cutoffDate);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record AdminDailyActiveUserSummary(
|
||||
LocalDate metricDate,
|
||||
String label,
|
||||
long userCount,
|
||||
List<String> usernames
|
||||
) {
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@Service
|
||||
@@ -18,13 +19,16 @@ public class AdminMetricsService {
|
||||
|
||||
private static final Long STATE_ID = 1L;
|
||||
private static final long DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES = 20L * 1024 * 1024 * 1024;
|
||||
private static final int DAILY_ACTIVE_USER_RETENTION_DAYS = 7;
|
||||
|
||||
private final AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||
private final AdminDailyActiveUserRepository adminDailyActiveUserRepository;
|
||||
|
||||
@Transactional
|
||||
public AdminMetricsSnapshot getSnapshot() {
|
||||
LocalDate today = LocalDate.now();
|
||||
pruneExpiredDailyActiveUsers(today);
|
||||
AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true);
|
||||
return toSnapshot(state, today);
|
||||
}
|
||||
@@ -34,6 +38,21 @@ public class AdminMetricsService {
|
||||
return ensureCurrentState().getOfflineTransferStorageLimitBytes();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordUserOnline(Long userId, String username) {
|
||||
if (userId == null || username == null || username.isBlank()) {
|
||||
return;
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
pruneExpiredDailyActiveUsers(today);
|
||||
AdminDailyActiveUserEntity entry = adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(today, userId)
|
||||
.orElseGet(() -> createDailyActiveUser(today, userId, username));
|
||||
if (!username.equals(entry.getUsername())) {
|
||||
entry.setUsername(username);
|
||||
adminDailyActiveUserRepository.save(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void incrementRequestCount() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -78,6 +97,7 @@ public class AdminMetricsService {
|
||||
state.getDownloadTrafficBytes(),
|
||||
state.getTransferUsageBytes(),
|
||||
state.getOfflineTransferStorageLimitBytes(),
|
||||
buildDailyActiveUsers(metricDate),
|
||||
buildRequestTimeline(metricDate)
|
||||
);
|
||||
}
|
||||
@@ -129,11 +149,34 @@ public class AdminMetricsService {
|
||||
for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) {
|
||||
countsByHour.put(point.getHour(), point.getRequestCount());
|
||||
}
|
||||
return IntStream.range(0, 24)
|
||||
int currentHour = LocalDate.now().equals(metricDate) ? LocalDateTime.now().getHour() : 23;
|
||||
return IntStream.rangeClosed(0, currentHour)
|
||||
.mapToObj(hour -> new AdminRequestTimelinePoint(hour, formatHourLabel(hour), countsByHour.getOrDefault(hour, 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<AdminDailyActiveUserSummary> buildDailyActiveUsers(LocalDate today) {
|
||||
LocalDate startDate = today.minusDays(DAILY_ACTIVE_USER_RETENTION_DAYS - 1L);
|
||||
Map<LocalDate, java.util.List<String>> usernamesByDate = new TreeMap<>();
|
||||
for (AdminDailyActiveUserEntity entry : adminDailyActiveUserRepository
|
||||
.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(startDate, today)) {
|
||||
usernamesByDate.computeIfAbsent(entry.getMetricDate(), ignored -> new java.util.ArrayList<>())
|
||||
.add(entry.getUsername());
|
||||
}
|
||||
return IntStream.range(0, DAILY_ACTIVE_USER_RETENTION_DAYS)
|
||||
.mapToObj(offset -> startDate.plusDays(offset))
|
||||
.map(metricDate -> {
|
||||
List<String> usernames = List.copyOf(usernamesByDate.getOrDefault(metricDate, List.of()));
|
||||
return new AdminDailyActiveUserSummary(
|
||||
metricDate,
|
||||
formatDailyActiveUserLabel(metricDate, today),
|
||||
usernames.size(),
|
||||
usernames
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void incrementRequestTimelinePoint(LocalDate metricDate, int hour) {
|
||||
AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository
|
||||
.findByMetricDateAndHourForUpdate(metricDate, hour)
|
||||
@@ -155,7 +198,34 @@ public class AdminMetricsService {
|
||||
}
|
||||
}
|
||||
|
||||
private AdminDailyActiveUserEntity createDailyActiveUser(LocalDate metricDate, Long userId, String username) {
|
||||
AdminDailyActiveUserEntity entry = new AdminDailyActiveUserEntity();
|
||||
entry.setMetricDate(metricDate);
|
||||
entry.setUserId(userId);
|
||||
entry.setUsername(username);
|
||||
try {
|
||||
return adminDailyActiveUserRepository.saveAndFlush(entry);
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
return adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(metricDate, userId)
|
||||
.orElseThrow(() -> ignored);
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneExpiredDailyActiveUsers(LocalDate today) {
|
||||
adminDailyActiveUserRepository.deleteAllByMetricDateBefore(today.minusDays(DAILY_ACTIVE_USER_RETENTION_DAYS - 1L));
|
||||
}
|
||||
|
||||
private String formatHourLabel(int hour) {
|
||||
return "%02d:00".formatted(hour);
|
||||
}
|
||||
|
||||
private String formatDailyActiveUserLabel(LocalDate metricDate, LocalDate today) {
|
||||
if (metricDate.equals(today)) {
|
||||
return "今天";
|
||||
}
|
||||
if (metricDate.equals(today.minusDays(1))) {
|
||||
return "昨天";
|
||||
}
|
||||
return "%02d-%02d".formatted(metricDate.getMonthValue(), metricDate.getDayOfMonth());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public record AdminMetricsSnapshot(
|
||||
long downloadTrafficBytes,
|
||||
long transferUsageBytes,
|
||||
long offlineTransferStorageLimitBytes,
|
||||
List<AdminDailyActiveUserSummary> dailyActiveUsers,
|
||||
List<AdminRequestTimelinePoint> requestTimeline
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.yoyuzh.auth.RefreshTokenService;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.FileBlobRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
@@ -32,6 +33,7 @@ public class AdminService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileBlobRepository fileBlobRepository;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
@@ -45,12 +47,13 @@ public class AdminService {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
storedFileRepository.sumAllFileSize(),
|
||||
fileBlobRepository.sumAllBlobSize(),
|
||||
metrics.downloadTrafficBytes(),
|
||||
metrics.requestCount(),
|
||||
metrics.transferUsageBytes(),
|
||||
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
|
||||
metrics.offlineTransferStorageLimitBytes(),
|
||||
metrics.dailyActiveUsers(),
|
||||
metrics.requestTimeline(),
|
||||
registrationInviteService.getCurrentInviteCode()
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ public record AdminSummaryResponse(
|
||||
long transferUsageBytes,
|
||||
long offlineTransferStorageBytes,
|
||||
long offlineTransferStorageLimitBytes,
|
||||
List<AdminDailyActiveUserSummary> dailyActiveUsers,
|
||||
List<AdminRequestTimelinePoint> requestTimeline,
|
||||
String inviteCode
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
@@ -11,10 +9,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@@ -59,7 +54,6 @@ public class DevBootstrapDataInitializer implements CommandLineRunner {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final FileService fileService;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileStorageProperties fileStorageProperties;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -103,33 +97,17 @@ public class DevBootstrapDataInitializer implements CommandLineRunner {
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), file.path(), file.filename())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Path filePath = resolveFilePath(user.getId(), file.path(), file.filename());
|
||||
try {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
Files.writeString(filePath, file.content(), StandardCharsets.UTF_8);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("无法初始化开发样例文件: " + file.filename(), ex);
|
||||
}
|
||||
|
||||
StoredFile storedFile = new StoredFile();
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(file.filename());
|
||||
storedFile.setPath(file.path());
|
||||
storedFile.setStorageName(file.filename());
|
||||
storedFile.setContentType(file.contentType());
|
||||
storedFile.setSize((long) file.content().getBytes(StandardCharsets.UTF_8).length);
|
||||
storedFile.setDirectory(false);
|
||||
storedFileRepository.save(storedFile);
|
||||
fileService.importExternalFile(
|
||||
user,
|
||||
file.path(),
|
||||
file.filename(),
|
||||
file.contentType(),
|
||||
file.content().getBytes(StandardCharsets.UTF_8).length,
|
||||
file.content().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveFilePath(Long userId, String path, String filename) {
|
||||
Path rootPath = Path.of(fileStorageProperties.getRootDir()).toAbsolutePath().normalize();
|
||||
String normalizedPath = path.startsWith("/") ? path.substring(1) : path;
|
||||
return rootPath.resolve(userId.toString()).resolve(normalizedPath).resolve(filename).normalize();
|
||||
}
|
||||
|
||||
private record DemoUserSpec(
|
||||
String username,
|
||||
String password,
|
||||
|
||||
@@ -11,6 +11,11 @@ public class CorsProperties {
|
||||
private List<String> allowedOrigins = new ArrayList<>(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost",
|
||||
"https://localhost",
|
||||
"http://127.0.0.1",
|
||||
"https://127.0.0.1",
|
||||
"capacitor://localhost",
|
||||
"https://yoyuzh.xyz",
|
||||
"https://www.yoyuzh.xyz"
|
||||
));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.JwtTokenProvider;
|
||||
import com.yoyuzh.auth.User;
|
||||
@@ -24,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
@@ -55,6 +57,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
adminMetricsService.recordUserOnline(domainUser.getId(), domainUser.getUsername());
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
83
backend/src/main/java/com/yoyuzh/files/FileBlob.java
Normal file
83
backend/src/main/java/com/yoyuzh/files/FileBlob.java
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file_blob", indexes = {
|
||||
@Index(name = "uk_file_blob_object_key", columnList = "object_key", unique = true),
|
||||
@Index(name = "idx_file_blob_created_at", columnList = "created_at")
|
||||
})
|
||||
public class FileBlob {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "object_key", nullable = false, length = 512, unique = true)
|
||||
private String objectKey;
|
||||
|
||||
@Column(name = "content_type", length = 255)
|
||||
private String contentType;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long size;
|
||||
|
||||
@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 String getObjectKey() {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
public void setObjectKey(String objectKey) {
|
||||
this.objectKey = objectKey;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public void setContentType(String contentType) {
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public Long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(Long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
@Order(0)
|
||||
@RequiredArgsConstructor
|
||||
public class FileBlobBackfillService implements CommandLineRunner {
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileBlobRepository fileBlobRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
backfillMissingBlobs();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void backfillMissingBlobs() {
|
||||
for (StoredFile storedFile : storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()) {
|
||||
String legacyStorageName = storedFile.getLegacyStorageName();
|
||||
if (!StringUtils.hasText(legacyStorageName)) {
|
||||
throw new IllegalStateException("文件缺少 blob 引用且没有 legacy storage_name: " + storedFile.getId());
|
||||
}
|
||||
|
||||
String objectKey = fileContentStorage.resolveLegacyFileObjectKey(
|
||||
storedFile.getUser().getId(),
|
||||
storedFile.getPath(),
|
||||
legacyStorageName
|
||||
);
|
||||
FileBlob blob = fileBlobRepository.findByObjectKey(objectKey)
|
||||
.orElseGet(() -> createBlob(storedFile, objectKey));
|
||||
storedFile.setBlob(blob);
|
||||
storedFileRepository.save(storedFile);
|
||||
}
|
||||
}
|
||||
|
||||
private FileBlob createBlob(StoredFile storedFile, String objectKey) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setContentType(storedFile.getContentType());
|
||||
blob.setSize(storedFile.getSize());
|
||||
return fileBlobRepository.save(blob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileBlobRepository extends JpaRepository<FileBlob, Long> {
|
||||
|
||||
Optional<FileBlob> findByObjectKey(String objectKey);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(b.size), 0)
|
||||
from FileBlob b
|
||||
""")
|
||||
long sumAllBlobSize();
|
||||
}
|
||||
@@ -25,8 +25,10 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -37,17 +39,20 @@ public class FileService {
|
||||
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileBlobRepository fileBlobRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final FileShareLinkRepository fileShareLinkRepository;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final long maxFileSize;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository,
|
||||
FileBlobRepository fileBlobRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileShareLinkRepository fileShareLinkRepository,
|
||||
AdminMetricsService adminMetricsService,
|
||||
FileStorageProperties properties) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileBlobRepository = fileBlobRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||
this.adminMetricsService = adminMetricsService;
|
||||
@@ -61,8 +66,12 @@ public class FileService {
|
||||
validateUpload(user, normalizedPath, filename, multipartFile.getSize());
|
||||
ensureDirectoryHierarchy(user, normalizedPath);
|
||||
|
||||
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
|
||||
return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize());
|
||||
String objectKey = createBlobObjectKey();
|
||||
return executeAfterBlobStored(objectKey, () -> {
|
||||
fileContentStorage.uploadBlob(objectKey, multipartFile);
|
||||
FileBlob blob = createAndSaveBlob(objectKey, multipartFile.getContentType(), multipartFile.getSize());
|
||||
return saveFileMetadata(user, normalizedPath, filename, multipartFile.getContentType(), multipartFile.getSize(), blob);
|
||||
});
|
||||
}
|
||||
|
||||
public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) {
|
||||
@@ -70,10 +79,11 @@ public class FileService {
|
||||
String filename = normalizeLeafName(request.filename());
|
||||
validateUpload(user, normalizedPath, filename, request.size());
|
||||
|
||||
PreparedUpload preparedUpload = fileContentStorage.prepareUpload(
|
||||
user.getId(),
|
||||
String objectKey = createBlobObjectKey();
|
||||
PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload(
|
||||
normalizedPath,
|
||||
filename,
|
||||
objectKey,
|
||||
request.contentType(),
|
||||
request.size()
|
||||
);
|
||||
@@ -91,12 +101,15 @@ public class FileService {
|
||||
public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) {
|
||||
String normalizedPath = normalizeDirectoryPath(request.path());
|
||||
String filename = normalizeLeafName(request.filename());
|
||||
String storageName = normalizeLeafName(request.storageName());
|
||||
String objectKey = normalizeBlobObjectKey(request.storageName());
|
||||
validateUpload(user, normalizedPath, filename, request.size());
|
||||
ensureDirectoryHierarchy(user, normalizedPath);
|
||||
|
||||
fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size());
|
||||
return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size());
|
||||
return executeAfterBlobStored(objectKey, () -> {
|
||||
fileContentStorage.completeBlobUpload(objectKey, request.contentType(), request.size());
|
||||
FileBlob blob = createAndSaveBlob(objectKey, request.contentType(), request.size());
|
||||
return saveFileMetadata(user, normalizedPath, filename, request.contentType(), request.size(), blob);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -117,7 +130,6 @@ public class FileService {
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(directoryName);
|
||||
storedFile.setPath(parentPath);
|
||||
storedFile.setStorageName(directoryName);
|
||||
storedFile.setContentType("directory");
|
||||
storedFile.setSize(0L);
|
||||
storedFile.setDirectory(true);
|
||||
@@ -153,7 +165,6 @@ public class FileService {
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(directoryName);
|
||||
storedFile.setPath("/");
|
||||
storedFile.setStorageName(directoryName);
|
||||
storedFile.setContentType("directory");
|
||||
storedFile.setSize(0L);
|
||||
storedFile.setDirectory(true);
|
||||
@@ -164,17 +175,20 @@ public class FileService {
|
||||
@Transactional
|
||||
public void delete(User user, Long fileId) {
|
||||
StoredFile storedFile = getOwnedFile(user, fileId, "删除");
|
||||
List<StoredFile> filesToDelete = new ArrayList<>();
|
||||
if (storedFile.isDirectory()) {
|
||||
String logicalPath = buildLogicalPath(storedFile);
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath);
|
||||
fileContentStorage.deleteDirectory(user.getId(), logicalPath, descendants);
|
||||
filesToDelete.addAll(descendants.stream().filter(descendant -> !descendant.isDirectory()).toList());
|
||||
if (!descendants.isEmpty()) {
|
||||
storedFileRepository.deleteAll(descendants);
|
||||
}
|
||||
} else {
|
||||
fileContentStorage.deleteFile(user.getId(), storedFile.getPath(), storedFile.getStorageName());
|
||||
filesToDelete.add(storedFile);
|
||||
}
|
||||
List<FileBlob> blobsToDelete = collectBlobsToDelete(filesToDelete);
|
||||
storedFileRepository.delete(storedFile);
|
||||
deleteBlobs(blobsToDelete);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -195,7 +209,6 @@ public class FileService {
|
||||
: storedFile.getPath() + "/" + sanitizedFilename;
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
||||
for (StoredFile descendant : descendants) {
|
||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||
descendant.setPath(newLogicalPath);
|
||||
@@ -207,12 +220,9 @@ public class FileService {
|
||||
if (!descendants.isEmpty()) {
|
||||
storedFileRepository.saveAll(descendants);
|
||||
}
|
||||
} else {
|
||||
fileContentStorage.renameFile(user.getId(), storedFile.getPath(), storedFile.getStorageName(), sanitizedFilename);
|
||||
}
|
||||
|
||||
storedFile.setFilename(sanitizedFilename);
|
||||
storedFile.setStorageName(sanitizedFilename);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@@ -239,7 +249,6 @@ public class FileService {
|
||||
}
|
||||
|
||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
||||
for (StoredFile descendant : descendants) {
|
||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||
descendant.setPath(newLogicalPath);
|
||||
@@ -251,8 +260,6 @@ public class FileService {
|
||||
if (!descendants.isEmpty()) {
|
||||
storedFileRepository.saveAll(descendants);
|
||||
}
|
||||
} else {
|
||||
fileContentStorage.moveFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
}
|
||||
|
||||
storedFile.setPath(normalizedTargetPath);
|
||||
@@ -270,8 +277,7 @@ public class FileService {
|
||||
|
||||
if (!storedFile.isDirectory()) {
|
||||
ensureWithinStorageQuota(user, storedFile.getSize());
|
||||
fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
||||
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath)));
|
||||
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, user, normalizedTargetPath)));
|
||||
}
|
||||
|
||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||
@@ -288,8 +294,7 @@ public class FileService {
|
||||
ensureWithinStorageQuota(user, additionalBytes);
|
||||
List<StoredFile> copiedEntries = new ArrayList<>();
|
||||
|
||||
fileContentStorage.ensureDirectory(user.getId(), newLogicalPath);
|
||||
StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath);
|
||||
StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath);
|
||||
copiedEntries.add(copiedRoot);
|
||||
|
||||
descendants.stream()
|
||||
@@ -303,12 +308,7 @@ public class FileService {
|
||||
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));
|
||||
copiedEntries.add(copyStoredFile(descendant, user, copiedPath));
|
||||
});
|
||||
|
||||
StoredFile savedRoot = null;
|
||||
@@ -329,10 +329,8 @@ public class FileService {
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return ResponseEntity.status(302)
|
||||
.location(URI.create(fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
storedFile.getPath(),
|
||||
storedFile.getStorageName(),
|
||||
.location(URI.create(fileContentStorage.createBlobDownloadUrl(
|
||||
getRequiredBlob(storedFile).getObjectKey(),
|
||||
storedFile.getFilename())))
|
||||
.build();
|
||||
}
|
||||
@@ -342,7 +340,7 @@ public class FileService {
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
|
||||
.contentType(MediaType.parseMediaType(
|
||||
storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType()))
|
||||
.body(fileContentStorage.readFile(user.getId(), storedFile.getPath(), storedFile.getStorageName()));
|
||||
.body(fileContentStorage.readBlob(getRequiredBlob(storedFile).getObjectKey()));
|
||||
}
|
||||
|
||||
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
|
||||
@@ -353,10 +351,8 @@ public class FileService {
|
||||
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return new DownloadUrlResponse(fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
storedFile.getPath(),
|
||||
storedFile.getStorageName(),
|
||||
return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl(
|
||||
getRequiredBlob(storedFile).getObjectKey(),
|
||||
storedFile.getFilename()
|
||||
));
|
||||
}
|
||||
@@ -407,19 +403,13 @@ public class FileService {
|
||||
if (sourceFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
|
||||
}
|
||||
|
||||
byte[] content = fileContentStorage.readFile(
|
||||
sourceFile.getUser().getId(),
|
||||
sourceFile.getPath(),
|
||||
sourceFile.getStorageName()
|
||||
);
|
||||
return importExternalFile(
|
||||
return importReferencedBlob(
|
||||
recipient,
|
||||
path,
|
||||
sourceFile.getFilename(),
|
||||
sourceFile.getContentType(),
|
||||
sourceFile.getSize(),
|
||||
content
|
||||
getRequiredBlob(sourceFile)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -434,22 +424,20 @@ public class FileService {
|
||||
String normalizedFilename = normalizeLeafName(filename);
|
||||
validateUpload(recipient, normalizedPath, normalizedFilename, size);
|
||||
ensureDirectoryHierarchy(recipient, normalizedPath);
|
||||
fileContentStorage.storeImportedFile(
|
||||
recipient.getId(),
|
||||
normalizedPath,
|
||||
normalizedFilename,
|
||||
contentType,
|
||||
content
|
||||
);
|
||||
String objectKey = createBlobObjectKey();
|
||||
return executeAfterBlobStored(objectKey, () -> {
|
||||
fileContentStorage.storeBlob(objectKey, contentType, content);
|
||||
FileBlob blob = createAndSaveBlob(objectKey, contentType, size);
|
||||
|
||||
return saveFileMetadata(
|
||||
recipient,
|
||||
normalizedPath,
|
||||
normalizedFilename,
|
||||
normalizedFilename,
|
||||
contentType,
|
||||
size
|
||||
);
|
||||
return saveFileMetadata(
|
||||
recipient,
|
||||
normalizedPath,
|
||||
normalizedFilename,
|
||||
contentType,
|
||||
size,
|
||||
blob
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
||||
@@ -475,7 +463,7 @@ public class FileService {
|
||||
|
||||
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName);
|
||||
writeFileEntry(zipOutputStream, createdEntries, entryName,
|
||||
fileContentStorage.readFile(user.getId(), descendant.getPath(), descendant.getStorageName()));
|
||||
fileContentStorage.readBlob(getRequiredBlob(descendant).getObjectKey()));
|
||||
}
|
||||
zipOutputStream.finish();
|
||||
archiveBytes = outputStream.toByteArray();
|
||||
@@ -493,17 +481,17 @@ public class FileService {
|
||||
private FileMetadataResponse saveFileMetadata(User user,
|
||||
String normalizedPath,
|
||||
String filename,
|
||||
String storageName,
|
||||
String contentType,
|
||||
long size) {
|
||||
long size,
|
||||
FileBlob blob) {
|
||||
StoredFile storedFile = new StoredFile();
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(filename);
|
||||
storedFile.setPath(normalizedPath);
|
||||
storedFile.setStorageName(storageName);
|
||||
storedFile.setContentType(contentType);
|
||||
storedFile.setSize(size);
|
||||
storedFile.setDirectory(false);
|
||||
storedFile.setBlob(blob);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@@ -513,7 +501,7 @@ public class FileService {
|
||||
}
|
||||
|
||||
private StoredFile getOwnedFile(User user, Long fileId, String action) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
StoredFile storedFile = storedFileRepository.findDetailedById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
if (!storedFile.getUser().getId().equals(user.getId())) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件");
|
||||
@@ -565,7 +553,6 @@ public class FileService {
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(segment);
|
||||
storedFile.setPath(currentPath);
|
||||
storedFile.setStorageName(segment);
|
||||
storedFile.setContentType("directory");
|
||||
storedFile.setSize(0L);
|
||||
storedFile.setDirectory(true);
|
||||
@@ -658,15 +645,15 @@ public class FileService {
|
||||
return newLogicalPath + currentPath.substring(oldLogicalPath.length());
|
||||
}
|
||||
|
||||
private StoredFile copyStoredFile(StoredFile source, String nextPath) {
|
||||
private StoredFile copyStoredFile(StoredFile source, User owner, String nextPath) {
|
||||
StoredFile copiedFile = new StoredFile();
|
||||
copiedFile.setUser(source.getUser());
|
||||
copiedFile.setUser(owner);
|
||||
copiedFile.setFilename(source.getFilename());
|
||||
copiedFile.setPath(nextPath);
|
||||
copiedFile.setStorageName(source.getStorageName());
|
||||
copiedFile.setContentType(source.getContentType());
|
||||
copiedFile.setSize(source.getSize());
|
||||
copiedFile.setDirectory(source.isDirectory());
|
||||
copiedFile.setBlob(source.getBlob());
|
||||
return copiedFile;
|
||||
}
|
||||
|
||||
@@ -717,4 +704,108 @@ public class FileService {
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String createBlobObjectKey() {
|
||||
return "blobs/" + UUID.randomUUID();
|
||||
}
|
||||
|
||||
private String normalizeBlobObjectKey(String objectKey) {
|
||||
String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).trim().replace("\\", "/");
|
||||
if (!StringUtils.hasText(cleaned) || cleaned.contains("..") || cleaned.startsWith("/") || !cleaned.startsWith("blobs/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "上传对象标识不合法");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private <T> T executeAfterBlobStored(String objectKey, BlobWriteOperation<T> operation) {
|
||||
try {
|
||||
return operation.run();
|
||||
} catch (RuntimeException ex) {
|
||||
try {
|
||||
fileContentStorage.deleteBlob(objectKey);
|
||||
} catch (RuntimeException cleanupEx) {
|
||||
ex.addSuppressed(cleanupEx);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private FileBlob createAndSaveBlob(String objectKey, String contentType, long size) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setContentType(contentType);
|
||||
blob.setSize(size);
|
||||
return fileBlobRepository.save(blob);
|
||||
}
|
||||
|
||||
private FileMetadataResponse importReferencedBlob(User recipient,
|
||||
String path,
|
||||
String filename,
|
||||
String contentType,
|
||||
long size,
|
||||
FileBlob blob) {
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
String normalizedFilename = normalizeLeafName(filename);
|
||||
validateUpload(recipient, normalizedPath, normalizedFilename, size);
|
||||
ensureDirectoryHierarchy(recipient, normalizedPath);
|
||||
return saveFileMetadata(
|
||||
recipient,
|
||||
normalizedPath,
|
||||
normalizedFilename,
|
||||
contentType,
|
||||
size,
|
||||
blob
|
||||
);
|
||||
}
|
||||
|
||||
private FileBlob getRequiredBlob(StoredFile storedFile) {
|
||||
if (storedFile.isDirectory() || storedFile.getBlob() == null) {
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件内容不存在");
|
||||
}
|
||||
return storedFile.getBlob();
|
||||
}
|
||||
|
||||
private List<FileBlob> collectBlobsToDelete(List<StoredFile> filesToDelete) {
|
||||
Map<Long, BlobDeletionCandidate> candidates = new HashMap<>();
|
||||
for (StoredFile file : filesToDelete) {
|
||||
if (file.getBlob() == null || file.getBlob().getId() == null) {
|
||||
continue;
|
||||
}
|
||||
BlobDeletionCandidate candidate = candidates.computeIfAbsent(
|
||||
file.getBlob().getId(),
|
||||
ignored -> new BlobDeletionCandidate(file.getBlob())
|
||||
);
|
||||
candidate.referencesToDelete += 1;
|
||||
}
|
||||
|
||||
List<FileBlob> blobsToDelete = new ArrayList<>();
|
||||
for (BlobDeletionCandidate candidate : candidates.values()) {
|
||||
long currentReferences = storedFileRepository.countByBlobId(candidate.blob.getId());
|
||||
if (currentReferences == candidate.referencesToDelete) {
|
||||
blobsToDelete.add(candidate.blob);
|
||||
}
|
||||
}
|
||||
return blobsToDelete;
|
||||
}
|
||||
|
||||
private void deleteBlobs(List<FileBlob> blobsToDelete) {
|
||||
for (FileBlob blob : blobsToDelete) {
|
||||
fileContentStorage.deleteBlob(blob.getObjectKey());
|
||||
fileBlobRepository.delete(blob);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BlobDeletionCandidate {
|
||||
private final FileBlob blob;
|
||||
private long referencesToDelete;
|
||||
|
||||
private BlobDeletionCandidate(FileBlob blob) {
|
||||
this.blob = blob;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface BlobWriteOperation<T> {
|
||||
T run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,12 @@ public class StoredFile {
|
||||
@Column(nullable = false, length = 512)
|
||||
private String path;
|
||||
|
||||
@Column(name = "storage_name", nullable = false, length = 255)
|
||||
private String storageName;
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "blob_id")
|
||||
private FileBlob blob;
|
||||
|
||||
@Column(name = "storage_name", length = 255)
|
||||
private String legacyStorageName;
|
||||
|
||||
@Column(name = "content_type", length = 255)
|
||||
private String contentType;
|
||||
@@ -90,12 +94,20 @@ public class StoredFile {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public String getStorageName() {
|
||||
return storageName;
|
||||
public FileBlob getBlob() {
|
||||
return blob;
|
||||
}
|
||||
|
||||
public void setStorageName(String storageName) {
|
||||
this.storageName = storageName;
|
||||
public void setBlob(FileBlob blob) {
|
||||
this.blob = blob;
|
||||
}
|
||||
|
||||
public String getLegacyStorageName() {
|
||||
return legacyStorageName;
|
||||
}
|
||||
|
||||
public void setLegacyStorageName(String legacyStorageName) {
|
||||
this.legacyStorageName = legacyStorageName;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
|
||||
@@ -12,10 +12,10 @@ import java.util.Optional;
|
||||
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = "user")
|
||||
@EntityGraph(attributePaths = {"user", "blob"})
|
||||
Page<StoredFile> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = "user")
|
||||
@EntityGraph(attributePaths = {"user", "blob"})
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
join f.user u
|
||||
@@ -47,6 +47,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@EntityGraph(attributePaths = "blob")
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path
|
||||
@@ -56,6 +57,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
@Param("path") String path,
|
||||
Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = "blob")
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and (f.path = :path or f.path like concat(:path, '/%'))
|
||||
@@ -78,5 +80,22 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
""")
|
||||
long sumAllFileSize();
|
||||
|
||||
@EntityGraph(attributePaths = "blob")
|
||||
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
@Query("""
|
||||
select count(f)
|
||||
from StoredFile f
|
||||
where f.blob.id = :blobId
|
||||
""")
|
||||
long countByBlobId(@Param("blobId") Long blobId);
|
||||
|
||||
@EntityGraph(attributePaths = {"user", "blob"})
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.id = :id
|
||||
""")
|
||||
Optional<StoredFile> findDetailedById(@Param("id") Long id);
|
||||
|
||||
List<StoredFile> findAllByDirectoryFalseAndBlobIsNull();
|
||||
}
|
||||
|
||||
@@ -41,16 +41,14 @@ public class TransferController {
|
||||
|
||||
@Operation(summary = "通过取件码查找快传会话")
|
||||
@GetMapping("/sessions/lookup")
|
||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String pickupCode) {
|
||||
return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode));
|
||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
|
||||
return ApiResponse.success(transferService.lookupSession(pickupCode));
|
||||
}
|
||||
|
||||
@Operation(summary = "加入快传会话")
|
||||
@PostMapping("/sessions/{sessionId}/join")
|
||||
public ApiResponse<TransferSessionResponse> joinSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId));
|
||||
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
|
||||
return ApiResponse.success(transferService.joinSession(sessionId));
|
||||
}
|
||||
|
||||
@Operation(summary = "查看当前用户的离线快传列表")
|
||||
@@ -80,10 +78,9 @@ public class TransferController {
|
||||
|
||||
@Operation(summary = "下载离线快传文件")
|
||||
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
|
||||
public ResponseEntity<?> downloadOfflineFile(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId,
|
||||
public ResponseEntity<?> downloadOfflineFile(@PathVariable String sessionId,
|
||||
@PathVariable String fileId) {
|
||||
return transferService.downloadOfflineFile(userDetails != null, sessionId, fileId);
|
||||
return transferService.downloadOfflineFile(sessionId, fileId);
|
||||
}
|
||||
|
||||
@Operation(summary = "把离线快传文件存入网盘")
|
||||
|
||||
@@ -67,7 +67,7 @@ public class TransferService {
|
||||
return createOnlineSession(request);
|
||||
}
|
||||
|
||||
public LookupTransferSessionResponse lookupSession(boolean authenticated, String pickupCode) {
|
||||
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
||||
pruneExpiredSessions();
|
||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
||||
|
||||
@@ -78,12 +78,11 @@ public class TransferService {
|
||||
|
||||
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||
validateOfflineReadySession(offlineSession, "取件码不存在或已失效");
|
||||
return toLookupResponse(offlineSession);
|
||||
}
|
||||
|
||||
public TransferSessionResponse joinSession(boolean authenticated, String sessionId) {
|
||||
public TransferSessionResponse joinSession(String sessionId) {
|
||||
pruneExpiredSessions();
|
||||
|
||||
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
|
||||
@@ -98,7 +97,6 @@ public class TransferService {
|
||||
|
||||
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"));
|
||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||
validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效");
|
||||
return toSessionResponse(offlineSession);
|
||||
}
|
||||
@@ -171,9 +169,8 @@ public class TransferService {
|
||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
||||
}
|
||||
|
||||
public ResponseEntity<?> downloadOfflineFile(boolean authenticated, String sessionId, String fileId) {
|
||||
public ResponseEntity<?> downloadOfflineFile(String sessionId, String fileId) {
|
||||
pruneExpiredSessions();
|
||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
||||
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
||||
ensureOfflineFileUploaded(file);
|
||||
@@ -372,12 +369,6 @@ 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);
|
||||
|
||||
@@ -47,6 +47,11 @@ app:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
- http://127.0.0.1:3000
|
||||
- http://localhost
|
||||
- https://localhost
|
||||
- http://127.0.0.1
|
||||
- https://127.0.0.1
|
||||
- capacitor://localhost
|
||||
- https://yoyuzh.xyz
|
||||
- https://www.yoyuzh.xyz
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.admin.AdminMetricsStateRepository;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.files.FileBlob;
|
||||
import com.yoyuzh.files.FileBlobRepository;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
@@ -17,6 +19,7 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
@@ -56,9 +59,13 @@ class AdminControllerIntegrationTest {
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Autowired
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
@Autowired
|
||||
private OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
@Autowired
|
||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
@Autowired
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
private User portalUser;
|
||||
private User secondaryUser;
|
||||
@@ -69,6 +76,7 @@ class AdminControllerIntegrationTest {
|
||||
void setUp() {
|
||||
offlineTransferSessionRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
fileBlobRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
adminMetricsStateRepository.deleteAll();
|
||||
|
||||
@@ -88,33 +96,47 @@ class AdminControllerIntegrationTest {
|
||||
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
||||
secondaryUser = userRepository.save(secondaryUser);
|
||||
|
||||
FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L);
|
||||
storedFile = new StoredFile();
|
||||
storedFile.setUser(portalUser);
|
||||
storedFile.setFilename("report.pdf");
|
||||
storedFile.setPath("/");
|
||||
storedFile.setStorageName("report.pdf");
|
||||
storedFile.setContentType("application/pdf");
|
||||
storedFile.setSize(1024L);
|
||||
storedFile.setDirectory(false);
|
||||
storedFile.setBlob(reportBlob);
|
||||
storedFile.setCreatedAt(LocalDateTime.now());
|
||||
storedFile = storedFileRepository.save(storedFile);
|
||||
|
||||
FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L);
|
||||
secondaryFile = new StoredFile();
|
||||
secondaryFile.setUser(secondaryUser);
|
||||
secondaryFile.setFilename("notes.txt");
|
||||
secondaryFile.setPath("/docs");
|
||||
secondaryFile.setStorageName("notes.txt");
|
||||
secondaryFile.setContentType("text/plain");
|
||||
secondaryFile.setSize(256L);
|
||||
secondaryFile.setDirectory(false);
|
||||
secondaryFile.setBlob(notesBlob);
|
||||
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
|
||||
secondaryFile = storedFileRepository.save(secondaryFile);
|
||||
}
|
||||
|
||||
private FileBlob createBlob(String objectKey, String contentType, long size) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setContentType(contentType);
|
||||
blob.setSize(size);
|
||||
blob.setCreatedAt(LocalDateTime.now());
|
||||
return fileBlobRepository.save(blob);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin")
|
||||
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
|
||||
int currentHour = LocalTime.now().getHour();
|
||||
LocalDate today = LocalDate.now();
|
||||
adminMetricsService.recordUserOnline(portalUser.getId(), portalUser.getUsername());
|
||||
adminMetricsService.recordUserOnline(secondaryUser.getId(), secondaryUser.getUsername());
|
||||
|
||||
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -134,13 +156,18 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.data.totalStorageBytes").value(1280L))
|
||||
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1)))
|
||||
.andExpect(jsonPath("$.data.requestTimeline.length()").value(24))
|
||||
.andExpect(jsonPath("$.data.requestTimeline.length()").value(currentHour + 1))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].hour").value(currentHour))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour)))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1)))
|
||||
.andExpect(jsonPath("$.data.transferUsageBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber())
|
||||
.andExpect(jsonPath("$.data.dailyActiveUsers.length()").value(7))
|
||||
.andExpect(jsonPath("$.data.dailyActiveUsers[6].metricDate").value(today.toString()))
|
||||
.andExpect(jsonPath("$.data.dailyActiveUsers[6].userCount").value(2))
|
||||
.andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[0]").value("alice"))
|
||||
.andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[1]").value("bob"))
|
||||
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -22,12 +24,18 @@ class AdminMetricsServiceTest {
|
||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
@Mock
|
||||
private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||
@Mock
|
||||
private AdminDailyActiveUserRepository adminDailyActiveUserRepository;
|
||||
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository);
|
||||
adminMetricsService = new AdminMetricsService(
|
||||
adminMetricsStateRepository,
|
||||
adminRequestTimelinePointRepository,
|
||||
adminDailyActiveUserRepository
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,15 +49,21 @@ class AdminMetricsServiceTest {
|
||||
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state));
|
||||
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of());
|
||||
when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(LocalDate.now().minusDays(6), LocalDate.now()))
|
||||
.thenReturn(java.util.List.of());
|
||||
|
||||
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
|
||||
|
||||
assertThat(snapshot.requestCount()).isZero();
|
||||
assertThat(state.getRequestCount()).isZero();
|
||||
assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now());
|
||||
assertThat(snapshot.requestTimeline()).hasSize(24);
|
||||
assertThat(snapshot.requestTimeline()).hasSize(LocalTime.now().getHour() + 1);
|
||||
assertThat(snapshot.requestTimeline().get(0)).isEqualTo(new AdminRequestTimelinePoint(0, "00:00", 0L));
|
||||
assertThat(snapshot.dailyActiveUsers()).hasSize(7);
|
||||
assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(LocalDate.now());
|
||||
assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isZero();
|
||||
verify(adminMetricsStateRepository).save(state);
|
||||
verify(adminDailyActiveUserRepository).deleteAllByMetricDateBefore(LocalDate.now().minusDays(6));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,4 +90,46 @@ class AdminMetricsServiceTest {
|
||||
verify(adminMetricsStateRepository).save(state);
|
||||
verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordUniqueDailyActiveUserAndBuildSevenDayHistory() {
|
||||
LocalDate today = LocalDate.now();
|
||||
AdminDailyActiveUserEntity existing = new AdminDailyActiveUserEntity();
|
||||
existing.setMetricDate(today);
|
||||
existing.setUserId(7L);
|
||||
existing.setUsername("alice");
|
||||
|
||||
AdminDailyActiveUserEntity yesterday = new AdminDailyActiveUserEntity();
|
||||
yesterday.setMetricDate(today.minusDays(1));
|
||||
yesterday.setUserId(8L);
|
||||
yesterday.setUsername("bob");
|
||||
|
||||
when(adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(today, 7L)).thenReturn(Optional.of(existing));
|
||||
when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(today.minusDays(6), today))
|
||||
.thenReturn(java.util.List.of(yesterday, existing));
|
||||
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(createCurrentState(today)));
|
||||
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(today)).thenReturn(java.util.List.of());
|
||||
|
||||
adminMetricsService.recordUserOnline(7L, "alice");
|
||||
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
|
||||
|
||||
assertThat(snapshot.dailyActiveUsers()).hasSize(7);
|
||||
assertThat(snapshot.dailyActiveUsers().get(5).metricDate()).isEqualTo(today.minusDays(1));
|
||||
assertThat(snapshot.dailyActiveUsers().get(5).userCount()).isEqualTo(1L);
|
||||
assertThat(snapshot.dailyActiveUsers().get(5).usernames()).containsExactly("bob");
|
||||
assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(today);
|
||||
assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isEqualTo(1L);
|
||||
assertThat(snapshot.dailyActiveUsers().get(6).usernames()).containsExactly("alice");
|
||||
verify(adminDailyActiveUserRepository, never()).save(any(AdminDailyActiveUserEntity.class));
|
||||
verify(adminDailyActiveUserRepository, times(2)).deleteAllByMetricDateBefore(today.minusDays(6));
|
||||
}
|
||||
|
||||
private AdminMetricsState createCurrentState(LocalDate metricDate) {
|
||||
AdminMetricsState state = new AdminMetricsState();
|
||||
state.setId(1L);
|
||||
state.setRequestCount(0L);
|
||||
state.setRequestCountDate(metricDate);
|
||||
state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.FileBlobRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
@@ -42,6 +43,8 @@ class AdminServiceTest {
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Mock
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@@ -59,7 +62,7 @@ class AdminServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminService = new AdminService(
|
||||
userRepository, storedFileRepository, fileService,
|
||||
userRepository, storedFileRepository, fileBlobRepository, fileService,
|
||||
passwordEncoder, refreshTokenService, registrationInviteService,
|
||||
offlineTransferSessionRepository, adminMetricsService);
|
||||
}
|
||||
@@ -70,12 +73,16 @@ class AdminServiceTest {
|
||||
void shouldReturnSummaryWithCountsAndInviteCode() {
|
||||
when(userRepository.count()).thenReturn(5L);
|
||||
when(storedFileRepository.count()).thenReturn(42L);
|
||||
when(storedFileRepository.sumAllFileSize()).thenReturn(8192L);
|
||||
when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L);
|
||||
when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot(
|
||||
0L,
|
||||
0L,
|
||||
0L,
|
||||
20L * 1024 * 1024 * 1024,
|
||||
List.of(
|
||||
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")),
|
||||
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob"))
|
||||
),
|
||||
List.of(
|
||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||
@@ -94,6 +101,10 @@ class AdminServiceTest {
|
||||
assertThat(summary.transferUsageBytes()).isZero();
|
||||
assertThat(summary.offlineTransferStorageBytes()).isZero();
|
||||
assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L);
|
||||
assertThat(summary.dailyActiveUsers()).containsExactly(
|
||||
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")),
|
||||
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob"))
|
||||
);
|
||||
assertThat(summary.requestTimeline()).containsExactly(
|
||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -39,15 +36,9 @@ class DevBootstrapDataInitializerTest {
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@Mock
|
||||
private FileStorageProperties fileStorageProperties;
|
||||
|
||||
@InjectMocks
|
||||
private DevBootstrapDataInitializer initializer;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void shouldCreateInitialDevUsersWhenMissing() throws Exception {
|
||||
when(userRepository.findByUsername("portal-demo")).thenReturn(Optional.empty());
|
||||
@@ -57,7 +48,6 @@ class DevBootstrapDataInitializerTest {
|
||||
when(passwordEncoder.encode("study123456")).thenReturn("encoded-study-password");
|
||||
when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password");
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false);
|
||||
when(fileStorageProperties.getRootDir()).thenReturn(tempDir.toString());
|
||||
List<User> savedUsers = new ArrayList<>();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
||||
User user = invocation.getArgument(0);
|
||||
@@ -71,9 +61,9 @@ class DevBootstrapDataInitializerTest {
|
||||
|
||||
verify(userRepository, times(3)).save(any(User.class));
|
||||
verify(fileService, times(3)).ensureDefaultDirectories(any(User.class));
|
||||
verify(fileService, times(9)).importExternalFile(any(User.class), anyString(), anyString(), anyString(), anyLong(), any());
|
||||
org.assertj.core.api.Assertions.assertThat(savedUsers)
|
||||
.extracting(User::getUsername)
|
||||
.containsExactly("portal-demo", "portal-study", "portal-design");
|
||||
verify(storedFileRepository, times(9)).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.JwtTokenProvider;
|
||||
import com.yoyuzh.auth.User;
|
||||
@@ -33,13 +34,15 @@ class JwtAuthenticationFilterTest {
|
||||
@Mock
|
||||
private CustomUserDetailsService userDetailsService;
|
||||
@Mock
|
||||
private AdminMetricsService adminMetricsService;
|
||||
@Mock
|
||||
private FilterChain filterChain;
|
||||
|
||||
private JwtAuthenticationFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService);
|
||||
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService, adminMetricsService);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@@ -159,6 +162,7 @@ class JwtAuthenticationFilterTest {
|
||||
verify(filterChain).doFilter(request, response);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice");
|
||||
verify(adminMetricsService).recordUserOnline(1L, "alice");
|
||||
}
|
||||
|
||||
private User createDomainUser(String username, String sessionId) {
|
||||
|
||||
@@ -15,7 +15,15 @@ class SecurityConfigTest {
|
||||
CorsProperties corsProperties = new CorsProperties();
|
||||
|
||||
assertThat(corsProperties.getAllowedOrigins())
|
||||
.contains("https://yoyuzh.xyz", "https://www.yoyuzh.xyz");
|
||||
.contains(
|
||||
"http://localhost",
|
||||
"https://localhost",
|
||||
"http://127.0.0.1",
|
||||
"https://127.0.0.1",
|
||||
"capacitor://localhost",
|
||||
"https://yoyuzh.xyz",
|
||||
"https://www.yoyuzh.xyz"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileBlobBackfillServiceTest {
|
||||
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Mock
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
private FileBlobBackfillService backfillService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
backfillService = new FileBlobBackfillService(storedFileRepository, fileBlobRepository, fileContentStorage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateMissingBlobFromLegacyStorageName() {
|
||||
StoredFile legacyFile = createLegacyFile(10L, 7L, "/docs", "notes.txt", "notes.txt");
|
||||
when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile));
|
||||
when(fileContentStorage.resolveLegacyFileObjectKey(7L, "/docs", "notes.txt")).thenReturn("users/7/docs/notes.txt");
|
||||
when(fileBlobRepository.findByObjectKey("users/7/docs/notes.txt")).thenReturn(Optional.empty());
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> {
|
||||
FileBlob blob = invocation.getArgument(0);
|
||||
blob.setId(100L);
|
||||
return blob;
|
||||
});
|
||||
|
||||
backfillService.backfillMissingBlobs();
|
||||
|
||||
verify(fileBlobRepository).save(any(FileBlob.class));
|
||||
verify(storedFileRepository).save(legacyFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReuseExistingBlobWhenObjectKeyAlreadyBackfilled() {
|
||||
StoredFile legacyFile = createLegacyFile(11L, 8L, "/docs", "report.pdf", "report.pdf");
|
||||
FileBlob existingBlob = new FileBlob();
|
||||
existingBlob.setId(101L);
|
||||
existingBlob.setObjectKey("users/8/docs/report.pdf");
|
||||
existingBlob.setContentType("application/pdf");
|
||||
existingBlob.setSize(5L);
|
||||
when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile));
|
||||
when(fileContentStorage.resolveLegacyFileObjectKey(8L, "/docs", "report.pdf")).thenReturn("users/8/docs/report.pdf");
|
||||
when(fileBlobRepository.findByObjectKey("users/8/docs/report.pdf")).thenReturn(Optional.of(existingBlob));
|
||||
|
||||
backfillService.backfillMissingBlobs();
|
||||
|
||||
verify(fileBlobRepository, never()).save(any(FileBlob.class));
|
||||
verify(storedFileRepository).save(legacyFile);
|
||||
}
|
||||
|
||||
private StoredFile createLegacyFile(Long id, Long userId, String path, String filename, String legacyStorageName) {
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setUsername("user-" + userId);
|
||||
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setUser(user);
|
||||
file.setPath(path);
|
||||
file.setFilename(filename);
|
||||
file.setLegacyStorageName(legacyStorageName);
|
||||
file.setContentType("application/pdf");
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class FileServiceEdgeCaseTest {
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Mock
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
@Mock
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
@@ -44,7 +46,14 @@ class FileServiceEdgeCaseTest {
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
|
||||
fileService = new FileService(
|
||||
storedFileRepository,
|
||||
fileBlobRepository,
|
||||
fileContentStorage,
|
||||
fileShareLinkRepository,
|
||||
adminMetricsService,
|
||||
properties
|
||||
);
|
||||
}
|
||||
|
||||
// --- normalizeDirectoryPath edge cases ---
|
||||
@@ -131,9 +140,9 @@ class FileServiceEdgeCaseTest {
|
||||
void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() {
|
||||
User user = createUser(1L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||
when(fileContentStorage.createDownloadUrl(1L, "/docs", "notes.txt", "notes.txt"))
|
||||
when(fileContentStorage.createBlobDownloadUrl("blobs/blob-10", "notes.txt"))
|
||||
.thenReturn("https://cdn.example.com/notes.txt");
|
||||
|
||||
ResponseEntity<?> response = fileService.download(user, 10L);
|
||||
@@ -149,7 +158,7 @@ class FileServiceEdgeCaseTest {
|
||||
void shouldRejectCreatingShareLinkForDirectory() {
|
||||
User user = createUser(1L);
|
||||
StoredFile directory = createDirectory(10L, user, "/", "docs");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
|
||||
assertThatThrownBy(() -> fileService.createShareLink(user, 10L))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
@@ -162,7 +171,7 @@ class FileServiceEdgeCaseTest {
|
||||
void shouldRejectDownloadUrlForDirectory() {
|
||||
User user = createUser(1L);
|
||||
StoredFile directory = createDirectory(10L, user, "/", "docs");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
|
||||
assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
@@ -211,7 +220,7 @@ class FileServiceEdgeCaseTest {
|
||||
void shouldReturnUnchangedFileWhenRenameToSameName() {
|
||||
User user = createUser(1L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
|
||||
|
||||
FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt");
|
||||
|
||||
@@ -237,9 +246,10 @@ class FileServiceEdgeCaseTest {
|
||||
file.setUser(user);
|
||||
file.setFilename(filename);
|
||||
file.setPath(path);
|
||||
file.setStorageName(filename);
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setContentType("text/plain");
|
||||
file.setBlob(createBlob(id, "blobs/blob-" + id, 5L, "text/plain"));
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
return file;
|
||||
}
|
||||
@@ -249,6 +259,17 @@ class FileServiceEdgeCaseTest {
|
||||
dir.setDirectory(true);
|
||||
dir.setContentType("directory");
|
||||
dir.setSize(0L);
|
||||
dir.setBlob(null);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private FileBlob createBlob(Long id, String objectKey, Long size, String contentType) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setId(id);
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setSize(size);
|
||||
blob.setContentType(contentType);
|
||||
blob.setCreatedAt(LocalDateTime.now());
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -42,6 +43,9 @@ class FileServiceTest {
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
@Mock
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
@@ -56,7 +60,14 @@ class FileServiceTest {
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
|
||||
fileService = new FileService(
|
||||
storedFileRepository,
|
||||
fileBlobRepository,
|
||||
fileContentStorage,
|
||||
fileShareLinkRepository,
|
||||
adminMetricsService,
|
||||
properties
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,6 +76,11 @@ class FileServiceTest {
|
||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> {
|
||||
FileBlob blob = invocation.getArgument(0);
|
||||
blob.setId(100L);
|
||||
return blob;
|
||||
});
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile file = invocation.getArgument(0);
|
||||
file.setId(10L);
|
||||
@@ -75,22 +91,28 @@ class FileServiceTest {
|
||||
|
||||
assertThat(response.id()).isEqualTo(10L);
|
||||
assertThat(response.path()).isEqualTo("/docs");
|
||||
verify(fileContentStorage).upload(7L, "/docs", "notes.txt", multipartFile);
|
||||
verify(fileContentStorage).uploadBlob(org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq(multipartFile));
|
||||
verify(fileBlobRepository).save(org.mockito.ArgumentMatchers.argThat(blob ->
|
||||
blob.getObjectKey() != null
|
||||
&& blob.getObjectKey().startsWith("blobs/")
|
||||
&& blob.getSize().equals(5L)
|
||||
&& "text/plain".equals(blob.getContentType())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInitiateDirectUploadThroughStorage() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(fileContentStorage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
|
||||
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "notes.txt"));
|
||||
when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L)))
|
||||
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "blobs/upload-1"));
|
||||
|
||||
InitiateUploadResponse response = fileService.initiateUpload(user,
|
||||
new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L));
|
||||
|
||||
assertThat(response.direct()).isTrue();
|
||||
assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com");
|
||||
verify(fileContentStorage).prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
assertThat(response.storageName()).startsWith("blobs/");
|
||||
verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,20 +120,25 @@ class FileServiceTest {
|
||||
User user = createUser(7L);
|
||||
long uploadSize = 500L * 1024 * 1024;
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false);
|
||||
when(fileContentStorage.prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize))
|
||||
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "movie.zip"));
|
||||
when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize)))
|
||||
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "blobs/upload-2"));
|
||||
|
||||
InitiateUploadResponse response = fileService.initiateUpload(user,
|
||||
new InitiateUploadRequest("/docs", "movie.zip", "application/zip", uploadSize));
|
||||
|
||||
assertThat(response.direct()).isTrue();
|
||||
verify(fileContentStorage).prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize);
|
||||
verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCompleteDirectUploadAndPersistMetadata() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> {
|
||||
FileBlob blob = invocation.getArgument(0);
|
||||
blob.setId(101L);
|
||||
return blob;
|
||||
});
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile file = invocation.getArgument(0);
|
||||
file.setId(11L);
|
||||
@@ -119,10 +146,44 @@ class FileServiceTest {
|
||||
});
|
||||
|
||||
FileMetadataResponse response = fileService.completeUpload(user,
|
||||
new CompleteUploadRequest("/docs", "notes.txt", "notes.txt", "text/plain", 12L));
|
||||
new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-3", "text/plain", 12L));
|
||||
|
||||
assertThat(response.id()).isEqualTo(11L);
|
||||
verify(fileContentStorage).completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
verify(fileContentStorage).completeBlobUpload("blobs/upload-3", "text/plain", 12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteUploadedBlobWhenMetadataSaveFails() {
|
||||
User user = createUser(7L);
|
||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
||||
|
||||
assertThatThrownBy(() -> fileService.upload(user, "/docs", multipartFile))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("insert failed");
|
||||
|
||||
verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat(
|
||||
key -> key != null && key.startsWith("blobs/")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
||||
|
||||
assertThatThrownBy(() -> fileService.completeUpload(user,
|
||||
new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-fail", "text/plain", 12L)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("insert failed");
|
||||
|
||||
verify(fileContentStorage).deleteBlob("blobs/upload-fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -131,14 +192,15 @@ class FileServiceTest {
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
fileService.completeUpload(user,
|
||||
new CompleteUploadRequest("/projects/site", "logo.png", "logo.png", "image/png", 12L));
|
||||
new CompleteUploadRequest("/projects/site", "logo.png", "blobs/upload-4", "image/png", 12L));
|
||||
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/projects");
|
||||
verify(fileContentStorage).ensureDirectory(7L, "/projects/site");
|
||||
verify(fileContentStorage).completeUpload(7L, "/projects/site", "logo.png", "image/png", 12L);
|
||||
verify(fileContentStorage).completeBlobUpload("blobs/upload-4", "image/png", 12L);
|
||||
verify(storedFileRepository, times(3)).save(any(StoredFile.class));
|
||||
}
|
||||
|
||||
@@ -146,14 +208,14 @@ class FileServiceTest {
|
||||
void shouldRenameFileThroughConfiguredStorage() {
|
||||
User user = createUser(7L);
|
||||
StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt");
|
||||
|
||||
assertThat(response.filename()).isEqualTo("renamed.txt");
|
||||
verify(fileContentStorage).renameFile(7L, "/docs", "notes.txt", "renamed.txt");
|
||||
verify(fileContentStorage, never()).renameFile(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,7 +224,7 @@ class FileServiceTest {
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed-archive")).thenReturn(false);
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
@@ -171,7 +233,7 @@ class FileServiceTest {
|
||||
|
||||
assertThat(response.filename()).isEqualTo("renamed-archive");
|
||||
assertThat(childFile.getPath()).isEqualTo("/docs/renamed-archive");
|
||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile));
|
||||
verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -179,7 +241,7 @@ class FileServiceTest {
|
||||
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.findDetailedById(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));
|
||||
@@ -188,7 +250,7 @@ class FileServiceTest {
|
||||
|
||||
assertThat(response.path()).isEqualTo("/下载");
|
||||
assertThat(file.getPath()).isEqualTo("/下载");
|
||||
verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt");
|
||||
verify(fileContentStorage, never()).moveFile(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -197,7 +259,7 @@ class FileServiceTest {
|
||||
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.findDetailedById(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));
|
||||
@@ -209,7 +271,7 @@ class FileServiceTest {
|
||||
assertThat(response.path()).isEqualTo("/图片/archive");
|
||||
assertThat(directory.getPath()).isEqualTo("/图片");
|
||||
assertThat(childFile.getPath()).isEqualTo("/图片/archive");
|
||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile));
|
||||
verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -219,7 +281,7 @@ class FileServiceTest {
|
||||
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.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||
.thenReturn(Optional.of(docsDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||
@@ -235,9 +297,10 @@ class FileServiceTest {
|
||||
@Test
|
||||
void shouldCopyFileToAnotherDirectory() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||
FileBlob blob = createBlob(50L, "blobs/blob-copy", 5L, "text/plain");
|
||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt", blob);
|
||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(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 -> {
|
||||
@@ -252,7 +315,8 @@ class FileServiceTest {
|
||||
|
||||
assertThat(response.id()).isEqualTo(20L);
|
||||
assertThat(response.path()).isEqualTo("/下载");
|
||||
verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt");
|
||||
assertThat(file.getBlob()).isSameAs(blob);
|
||||
verify(fileContentStorage, never()).copyFile(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,9 +325,11 @@ class FileServiceTest {
|
||||
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));
|
||||
FileBlob childBlob = createBlob(51L, "blobs/blob-archive-1", 5L, "text/plain");
|
||||
FileBlob nestedBlob = createBlob(52L, "blobs/blob-archive-2", 5L, "text/plain");
|
||||
StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt", childBlob);
|
||||
StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt", nestedBlob);
|
||||
when(storedFileRepository.findDetailedById(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"))
|
||||
@@ -282,10 +348,7 @@ class FileServiceTest {
|
||||
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");
|
||||
verify(fileContentStorage, never()).copyFile(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -295,7 +358,7 @@ class FileServiceTest {
|
||||
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.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||
.thenReturn(Optional.of(docsDirectory));
|
||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||
@@ -313,7 +376,7 @@ class FileServiceTest {
|
||||
User owner = createUser(1L);
|
||||
User requester = createUser(2L);
|
||||
StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(100L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.findDetailedById(100L)).thenReturn(Optional.of(storedFile));
|
||||
|
||||
assertThatThrownBy(() -> fileService.delete(requester, 100L))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
@@ -324,18 +387,51 @@ class FileServiceTest {
|
||||
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
|
||||
User user = createUser(7L);
|
||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
|
||||
FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain");
|
||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob);
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||
when(storedFileRepository.countByBlobId(60L)).thenReturn(1L);
|
||||
|
||||
fileService.delete(user, 10L);
|
||||
|
||||
verify(fileContentStorage).deleteDirectory(7L, "/docs/archive", List.of(childFile));
|
||||
verify(fileContentStorage).deleteBlob("blobs/blob-delete");
|
||||
verify(fileBlobRepository).delete(blob);
|
||||
verify(storedFileRepository).deleteAll(List.of(childFile));
|
||||
verify(storedFileRepository).delete(directory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() {
|
||||
User user = createUser(7L);
|
||||
FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain");
|
||||
StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob);
|
||||
when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.countByBlobId(70L)).thenReturn(2L);
|
||||
|
||||
fileService.delete(user, 15L);
|
||||
|
||||
verify(fileContentStorage, never()).deleteBlob(any());
|
||||
verify(fileBlobRepository, never()).delete(any());
|
||||
verify(storedFileRepository).delete(storedFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() {
|
||||
User user = createUser(7L);
|
||||
FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain");
|
||||
StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob);
|
||||
when(storedFileRepository.findDetailedById(16L)).thenReturn(Optional.of(storedFile));
|
||||
when(storedFileRepository.countByBlobId(71L)).thenReturn(1L);
|
||||
|
||||
fileService.delete(user, 16L);
|
||||
|
||||
verify(fileContentStorage).deleteBlob("blobs/blob-last");
|
||||
verify(fileBlobRepository).delete(blob);
|
||||
verify(storedFileRepository).delete(storedFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldListFilesByPathWithPagination() {
|
||||
User user = createUser(7L);
|
||||
@@ -370,9 +466,9 @@ class FileServiceTest {
|
||||
void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||
when(fileContentStorage.createDownloadUrl(7L, "/docs", "notes.txt", "notes.txt"))
|
||||
when(fileContentStorage.createBlobDownloadUrl("blobs/blob-22", "notes.txt"))
|
||||
.thenReturn("https://download.example.com/file");
|
||||
|
||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||
@@ -384,7 +480,7 @@ class FileServiceTest {
|
||||
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileContentStorage.supportsDirectDownload()).thenReturn(false);
|
||||
|
||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||
@@ -401,12 +497,12 @@ class FileServiceTest {
|
||||
StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt");
|
||||
StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.txt");
|
||||
|
||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
|
||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
||||
.thenReturn(List.of(childDirectory, childFile, nestedFile));
|
||||
when(fileContentStorage.readFile(7L, "/docs/archive", "notes.txt"))
|
||||
when(fileContentStorage.readBlob("blobs/blob-12"))
|
||||
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
|
||||
when(fileContentStorage.readFile(7L, "/docs/archive/nested", "todo.txt"))
|
||||
when(fileContentStorage.readBlob("blobs/blob-13"))
|
||||
.thenReturn("world".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
var response = fileService.download(user, 10L);
|
||||
@@ -430,15 +526,15 @@ class FileServiceTest {
|
||||
assertThat(entries).containsEntry("archive/nested/", "");
|
||||
assertThat(entries).containsEntry("archive/notes.txt", "hello");
|
||||
assertThat(entries).containsEntry("archive/nested/todo.txt", "world");
|
||||
verify(fileContentStorage).readFile(7L, "/docs/archive", "notes.txt");
|
||||
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
|
||||
verify(fileContentStorage).readBlob("blobs/blob-12");
|
||||
verify(fileContentStorage).readBlob("blobs/blob-13");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateShareLinkForOwnedFile() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
|
||||
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
|
||||
when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> {
|
||||
FileShareLink shareLink = invocation.getArgument(0);
|
||||
shareLink.setId(100L);
|
||||
@@ -457,7 +553,8 @@ class FileServiceTest {
|
||||
void shouldImportSharedFileIntoRecipientWorkspace() {
|
||||
User owner = createUser(7L);
|
||||
User recipient = createUser(8L);
|
||||
StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt");
|
||||
FileBlob blob = createBlob(80L, "blobs/blob-import", 5L, "text/plain");
|
||||
StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt", blob);
|
||||
FileShareLink shareLink = new FileShareLink();
|
||||
shareLink.setId(100L);
|
||||
shareLink.setToken("share-token-1");
|
||||
@@ -471,21 +568,30 @@ class FileServiceTest {
|
||||
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))
|
||||
);
|
||||
verify(fileContentStorage, never()).storeImportedFile(any(), any(), any(), any(), any());
|
||||
verify(fileContentStorage, never()).readFile(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteImportedBlobWhenMetadataSaveFails() {
|
||||
User recipient = createUser(8L);
|
||||
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/", "下载")).thenReturn(true);
|
||||
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class));
|
||||
|
||||
assertThatThrownBy(() -> fileService.importExternalFile(recipient, "/下载", "notes.txt", "text/plain", content.length, content))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("insert failed");
|
||||
|
||||
verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat(
|
||||
key -> key != null && key.startsWith("blobs/")));
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
@@ -498,7 +604,21 @@ class FileServiceTest {
|
||||
return user;
|
||||
}
|
||||
|
||||
private FileBlob createBlob(Long id, String objectKey, Long size, String contentType) {
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setId(id);
|
||||
blob.setObjectKey(objectKey);
|
||||
blob.setSize(size);
|
||||
blob.setContentType(contentType);
|
||||
blob.setCreatedAt(LocalDateTime.now());
|
||||
return blob;
|
||||
}
|
||||
|
||||
private StoredFile createFile(Long id, User user, String path, String filename) {
|
||||
return createFile(id, user, path, filename, createBlob(id, "blobs/blob-" + id, 5L, "text/plain"));
|
||||
}
|
||||
|
||||
private StoredFile createFile(Long id, User user, String path, String filename, FileBlob blob) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setUser(user);
|
||||
@@ -506,7 +626,8 @@ class FileServiceTest {
|
||||
file.setPath(path);
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setStorageName(filename);
|
||||
file.setContentType("text/plain");
|
||||
file.setBlob(blob);
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
return file;
|
||||
}
|
||||
@@ -516,6 +637,7 @@ class FileServiceTest {
|
||||
directory.setDirectory(true);
|
||||
directory.setContentType("directory");
|
||||
directory.setSize(0L);
|
||||
directory.setBlob(null);
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
@@ -50,6 +53,8 @@ class FileShareControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Autowired
|
||||
private FileBlobRepository fileBlobRepository;
|
||||
|
||||
@Autowired
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
@@ -58,6 +63,7 @@ class FileShareControllerIntegrationTest {
|
||||
void setUp() throws Exception {
|
||||
fileShareLinkRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
fileBlobRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
if (Files.exists(STORAGE_ROOT)) {
|
||||
try (var paths = Files.walk(STORAGE_ROOT)) {
|
||||
@@ -88,19 +94,26 @@ class FileShareControllerIntegrationTest {
|
||||
recipient.setCreatedAt(LocalDateTime.now());
|
||||
recipient = userRepository.save(recipient);
|
||||
|
||||
FileBlob blob = new FileBlob();
|
||||
blob.setObjectKey("blobs/share-notes");
|
||||
blob.setContentType("text/plain");
|
||||
blob.setSize(5L);
|
||||
blob.setCreatedAt(LocalDateTime.now());
|
||||
blob = fileBlobRepository.save(blob);
|
||||
|
||||
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);
|
||||
file.setBlob(blob);
|
||||
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);
|
||||
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-notes");
|
||||
Files.createDirectories(blobPath.getParent());
|
||||
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -152,6 +165,18 @@ class FileShareControllerIntegrationTest {
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
|
||||
List<StoredFile> allFiles = storedFileRepository.findAll().stream()
|
||||
.filter(file -> !file.isDirectory())
|
||||
.sorted(Comparator.comparing(StoredFile::getId))
|
||||
.toList();
|
||||
assertThat(allFiles).hasSize(2);
|
||||
assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId());
|
||||
assertThat(fileBlobRepository.count()).isEqualTo(1L);
|
||||
try (var paths = Files.walk(STORAGE_ROOT)) {
|
||||
long physicalObjects = paths.filter(Files::isRegularFile).count();
|
||||
assertThat(physicalObjects).isEqualTo(1L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,7 +187,6 @@ class FileShareControllerIntegrationTest {
|
||||
downloadDirectory.setUser(owner);
|
||||
downloadDirectory.setFilename("下载");
|
||||
downloadDirectory.setPath("/");
|
||||
downloadDirectory.setStorageName("下载");
|
||||
downloadDirectory.setContentType("directory");
|
||||
downloadDirectory.setSize(0L);
|
||||
downloadDirectory.setDirectory(true);
|
||||
@@ -198,7 +222,6 @@ class FileShareControllerIntegrationTest {
|
||||
downloadDirectory.setUser(owner);
|
||||
downloadDirectory.setFilename("下载");
|
||||
downloadDirectory.setPath("/");
|
||||
downloadDirectory.setStorageName("下载");
|
||||
downloadDirectory.setContentType("directory");
|
||||
downloadDirectory.setSize(0L);
|
||||
downloadDirectory.setDirectory(true);
|
||||
@@ -224,5 +247,13 @@ class FileShareControllerIntegrationTest {
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
||||
|
||||
List<StoredFile> allFiles = storedFileRepository.findAll().stream()
|
||||
.filter(file -> !file.isDirectory())
|
||||
.sorted(Comparator.comparing(StoredFile::getId))
|
||||
.toList();
|
||||
assertThat(allFiles).hasSize(2);
|
||||
assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId());
|
||||
assertThat(fileBlobRepository.count()).isEqualTo(1L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class TransferControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice")
|
||||
void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception {
|
||||
void shouldAllowAnonymousOfflineLookupJoinAndDownloadButKeepImportProtected() throws Exception {
|
||||
String response = mockMvc.perform(post("/api/transfer/sessions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
@@ -176,12 +176,27 @@ class TransferControllerIntegrationTest {
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode))
|
||||
.andExpect(status().isUnauthorized());
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
||||
.andExpect(jsonPath("$.data.mode").value("OFFLINE"));
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId).with(anonymous()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
|
||||
.andExpect(jsonPath("$.data.files[0].name").value("offline.txt"));
|
||||
|
||||
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId).with(anonymous()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/files/{fileId}/import", sessionId, fileId)
|
||||
.with(anonymous())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"path": "/"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user