Fix Android WebView API access and mobile shell layout

This commit is contained in:
yoyuzh
2026-04-03 14:37:21 +08:00
parent f02ff9342f
commit 56f2a9fe0d
121 changed files with 4751 additions and 700 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ public record AdminMetricsSnapshot(
long downloadTrafficBytes,
long transferUsageBytes,
long offlineTransferStorageLimitBytes,
List<AdminDailyActiveUserSummary> dailyActiveUsers,
List<AdminRequestTimelinePoint> requestTimeline
) {
}

View File

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

View File

@@ -11,6 +11,7 @@ public record AdminSummaryResponse(
long transferUsageBytes,
long offlineTransferStorageBytes,
long offlineTransferStorageLimitBytes,
List<AdminDailyActiveUserSummary> dailyActiveUsers,
List<AdminRequestTimelinePoint> requestTimeline,
String inviteCode
) {

View File

@@ -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,

View File

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

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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 = "把离线快传文件存入网盘")

View File

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

View File

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