Fix Android WebView API access and mobile shell layout
@@ -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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
import java.util.stream.IntStream;
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -18,13 +19,16 @@ public class AdminMetricsService {
|
|||||||
|
|
||||||
private static final Long STATE_ID = 1L;
|
private static final Long STATE_ID = 1L;
|
||||||
private static final long DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES = 20L * 1024 * 1024 * 1024;
|
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 AdminMetricsStateRepository adminMetricsStateRepository;
|
||||||
private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||||
|
private final AdminDailyActiveUserRepository adminDailyActiveUserRepository;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AdminMetricsSnapshot getSnapshot() {
|
public AdminMetricsSnapshot getSnapshot() {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
|
pruneExpiredDailyActiveUsers(today);
|
||||||
AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true);
|
AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true);
|
||||||
return toSnapshot(state, today);
|
return toSnapshot(state, today);
|
||||||
}
|
}
|
||||||
@@ -34,6 +38,21 @@ public class AdminMetricsService {
|
|||||||
return ensureCurrentState().getOfflineTransferStorageLimitBytes();
|
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
|
@Transactional
|
||||||
public void incrementRequestCount() {
|
public void incrementRequestCount() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -78,6 +97,7 @@ public class AdminMetricsService {
|
|||||||
state.getDownloadTrafficBytes(),
|
state.getDownloadTrafficBytes(),
|
||||||
state.getTransferUsageBytes(),
|
state.getTransferUsageBytes(),
|
||||||
state.getOfflineTransferStorageLimitBytes(),
|
state.getOfflineTransferStorageLimitBytes(),
|
||||||
|
buildDailyActiveUsers(metricDate),
|
||||||
buildRequestTimeline(metricDate)
|
buildRequestTimeline(metricDate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,11 +149,34 @@ public class AdminMetricsService {
|
|||||||
for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) {
|
for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) {
|
||||||
countsByHour.put(point.getHour(), point.getRequestCount());
|
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)))
|
.mapToObj(hour -> new AdminRequestTimelinePoint(hour, formatHourLabel(hour), countsByHour.getOrDefault(hour, 0L)))
|
||||||
.toList();
|
.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) {
|
private void incrementRequestTimelinePoint(LocalDate metricDate, int hour) {
|
||||||
AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository
|
AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository
|
||||||
.findByMetricDateAndHourForUpdate(metricDate, hour)
|
.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) {
|
private String formatHourLabel(int hour) {
|
||||||
return "%02d:00".formatted(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 downloadTrafficBytes,
|
||||||
long transferUsageBytes,
|
long transferUsageBytes,
|
||||||
long offlineTransferStorageLimitBytes,
|
long offlineTransferStorageLimitBytes,
|
||||||
|
List<AdminDailyActiveUserSummary> dailyActiveUsers,
|
||||||
List<AdminRequestTimelinePoint> requestTimeline
|
List<AdminRequestTimelinePoint> requestTimeline
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.yoyuzh.auth.RefreshTokenService;
|
|||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.ErrorCode;
|
import com.yoyuzh.common.ErrorCode;
|
||||||
import com.yoyuzh.common.PageResponse;
|
import com.yoyuzh.common.PageResponse;
|
||||||
|
import com.yoyuzh.files.FileBlobRepository;
|
||||||
import com.yoyuzh.files.FileService;
|
import com.yoyuzh.files.FileService;
|
||||||
import com.yoyuzh.files.StoredFile;
|
import com.yoyuzh.files.StoredFile;
|
||||||
import com.yoyuzh.files.StoredFileRepository;
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
@@ -32,6 +33,7 @@ public class AdminService {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final StoredFileRepository storedFileRepository;
|
private final StoredFileRepository storedFileRepository;
|
||||||
|
private final FileBlobRepository fileBlobRepository;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RefreshTokenService refreshTokenService;
|
private final RefreshTokenService refreshTokenService;
|
||||||
@@ -45,12 +47,13 @@ public class AdminService {
|
|||||||
return new AdminSummaryResponse(
|
return new AdminSummaryResponse(
|
||||||
userRepository.count(),
|
userRepository.count(),
|
||||||
storedFileRepository.count(),
|
storedFileRepository.count(),
|
||||||
storedFileRepository.sumAllFileSize(),
|
fileBlobRepository.sumAllBlobSize(),
|
||||||
metrics.downloadTrafficBytes(),
|
metrics.downloadTrafficBytes(),
|
||||||
metrics.requestCount(),
|
metrics.requestCount(),
|
||||||
metrics.transferUsageBytes(),
|
metrics.transferUsageBytes(),
|
||||||
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
|
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
|
||||||
metrics.offlineTransferStorageLimitBytes(),
|
metrics.offlineTransferStorageLimitBytes(),
|
||||||
|
metrics.dailyActiveUsers(),
|
||||||
metrics.requestTimeline(),
|
metrics.requestTimeline(),
|
||||||
registrationInviteService.getCurrentInviteCode()
|
registrationInviteService.getCurrentInviteCode()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public record AdminSummaryResponse(
|
|||||||
long transferUsageBytes,
|
long transferUsageBytes,
|
||||||
long offlineTransferStorageBytes,
|
long offlineTransferStorageBytes,
|
||||||
long offlineTransferStorageLimitBytes,
|
long offlineTransferStorageLimitBytes,
|
||||||
|
List<AdminDailyActiveUserSummary> dailyActiveUsers,
|
||||||
List<AdminRequestTimelinePoint> requestTimeline,
|
List<AdminRequestTimelinePoint> requestTimeline,
|
||||||
String inviteCode
|
String inviteCode
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.yoyuzh.auth;
|
package com.yoyuzh.auth;
|
||||||
|
|
||||||
import com.yoyuzh.config.FileStorageProperties;
|
|
||||||
import com.yoyuzh.files.FileService;
|
import com.yoyuzh.files.FileService;
|
||||||
import com.yoyuzh.files.StoredFile;
|
|
||||||
import com.yoyuzh.files.StoredFileRepository;
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
@@ -11,10 +9,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -59,7 +54,6 @@ public class DevBootstrapDataInitializer implements CommandLineRunner {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final StoredFileRepository storedFileRepository;
|
private final StoredFileRepository storedFileRepository;
|
||||||
private final FileStorageProperties fileStorageProperties;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -103,33 +97,17 @@ public class DevBootstrapDataInitializer implements CommandLineRunner {
|
|||||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), file.path(), file.filename())) {
|
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), file.path(), file.filename())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
fileService.importExternalFile(
|
||||||
Path filePath = resolveFilePath(user.getId(), file.path(), file.filename());
|
user,
|
||||||
try {
|
file.path(),
|
||||||
Files.createDirectories(filePath.getParent());
|
file.filename(),
|
||||||
Files.writeString(filePath, file.content(), StandardCharsets.UTF_8);
|
file.contentType(),
|
||||||
} catch (IOException ex) {
|
file.content().getBytes(StandardCharsets.UTF_8).length,
|
||||||
throw new IllegalStateException("无法初始化开发样例文件: " + file.filename(), ex);
|
file.content().getBytes(StandardCharsets.UTF_8)
|
||||||
}
|
);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
private record DemoUserSpec(
|
||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ public class CorsProperties {
|
|||||||
private List<String> allowedOrigins = new ArrayList<>(List.of(
|
private List<String> allowedOrigins = new ArrayList<>(List.of(
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1: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://yoyuzh.xyz",
|
||||||
"https://www.yoyuzh.xyz"
|
"https://www.yoyuzh.xyz"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yoyuzh.config;
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.yoyuzh.admin.AdminMetricsService;
|
||||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||||
import com.yoyuzh.auth.JwtTokenProvider;
|
import com.yoyuzh.auth.JwtTokenProvider;
|
||||||
import com.yoyuzh.auth.User;
|
import com.yoyuzh.auth.User;
|
||||||
@@ -24,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
private final CustomUserDetailsService userDetailsService;
|
private final CustomUserDetailsService userDetailsService;
|
||||||
|
private final AdminMetricsService adminMetricsService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request,
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
@@ -55,6 +57,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
userDetails, null, userDetails.getAuthorities());
|
userDetails, null, userDetails.getAuthorities());
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
adminMetricsService.recordUserOnline(domainUser.getId(), domainUser.getUsername());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
|
|||||||
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.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
@@ -37,17 +39,20 @@ public class FileService {
|
|||||||
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
|
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
|
||||||
|
|
||||||
private final StoredFileRepository storedFileRepository;
|
private final StoredFileRepository storedFileRepository;
|
||||||
|
private final FileBlobRepository fileBlobRepository;
|
||||||
private final FileContentStorage fileContentStorage;
|
private final FileContentStorage fileContentStorage;
|
||||||
private final FileShareLinkRepository fileShareLinkRepository;
|
private final FileShareLinkRepository fileShareLinkRepository;
|
||||||
private final AdminMetricsService adminMetricsService;
|
private final AdminMetricsService adminMetricsService;
|
||||||
private final long maxFileSize;
|
private final long maxFileSize;
|
||||||
|
|
||||||
public FileService(StoredFileRepository storedFileRepository,
|
public FileService(StoredFileRepository storedFileRepository,
|
||||||
|
FileBlobRepository fileBlobRepository,
|
||||||
FileContentStorage fileContentStorage,
|
FileContentStorage fileContentStorage,
|
||||||
FileShareLinkRepository fileShareLinkRepository,
|
FileShareLinkRepository fileShareLinkRepository,
|
||||||
AdminMetricsService adminMetricsService,
|
AdminMetricsService adminMetricsService,
|
||||||
FileStorageProperties properties) {
|
FileStorageProperties properties) {
|
||||||
this.storedFileRepository = storedFileRepository;
|
this.storedFileRepository = storedFileRepository;
|
||||||
|
this.fileBlobRepository = fileBlobRepository;
|
||||||
this.fileContentStorage = fileContentStorage;
|
this.fileContentStorage = fileContentStorage;
|
||||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||||
this.adminMetricsService = adminMetricsService;
|
this.adminMetricsService = adminMetricsService;
|
||||||
@@ -61,8 +66,12 @@ public class FileService {
|
|||||||
validateUpload(user, normalizedPath, filename, multipartFile.getSize());
|
validateUpload(user, normalizedPath, filename, multipartFile.getSize());
|
||||||
ensureDirectoryHierarchy(user, normalizedPath);
|
ensureDirectoryHierarchy(user, normalizedPath);
|
||||||
|
|
||||||
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
|
String objectKey = createBlobObjectKey();
|
||||||
return saveFileMetadata(user, normalizedPath, filename, filename, multipartFile.getContentType(), multipartFile.getSize());
|
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) {
|
public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) {
|
||||||
@@ -70,10 +79,11 @@ public class FileService {
|
|||||||
String filename = normalizeLeafName(request.filename());
|
String filename = normalizeLeafName(request.filename());
|
||||||
validateUpload(user, normalizedPath, filename, request.size());
|
validateUpload(user, normalizedPath, filename, request.size());
|
||||||
|
|
||||||
PreparedUpload preparedUpload = fileContentStorage.prepareUpload(
|
String objectKey = createBlobObjectKey();
|
||||||
user.getId(),
|
PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload(
|
||||||
normalizedPath,
|
normalizedPath,
|
||||||
filename,
|
filename,
|
||||||
|
objectKey,
|
||||||
request.contentType(),
|
request.contentType(),
|
||||||
request.size()
|
request.size()
|
||||||
);
|
);
|
||||||
@@ -91,12 +101,15 @@ public class FileService {
|
|||||||
public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) {
|
public FileMetadataResponse completeUpload(User user, CompleteUploadRequest request) {
|
||||||
String normalizedPath = normalizeDirectoryPath(request.path());
|
String normalizedPath = normalizeDirectoryPath(request.path());
|
||||||
String filename = normalizeLeafName(request.filename());
|
String filename = normalizeLeafName(request.filename());
|
||||||
String storageName = normalizeLeafName(request.storageName());
|
String objectKey = normalizeBlobObjectKey(request.storageName());
|
||||||
validateUpload(user, normalizedPath, filename, request.size());
|
validateUpload(user, normalizedPath, filename, request.size());
|
||||||
ensureDirectoryHierarchy(user, normalizedPath);
|
ensureDirectoryHierarchy(user, normalizedPath);
|
||||||
|
|
||||||
fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size());
|
return executeAfterBlobStored(objectKey, () -> {
|
||||||
return saveFileMetadata(user, normalizedPath, filename, storageName, request.contentType(), request.size());
|
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
|
@Transactional
|
||||||
@@ -117,7 +130,6 @@ public class FileService {
|
|||||||
storedFile.setUser(user);
|
storedFile.setUser(user);
|
||||||
storedFile.setFilename(directoryName);
|
storedFile.setFilename(directoryName);
|
||||||
storedFile.setPath(parentPath);
|
storedFile.setPath(parentPath);
|
||||||
storedFile.setStorageName(directoryName);
|
|
||||||
storedFile.setContentType("directory");
|
storedFile.setContentType("directory");
|
||||||
storedFile.setSize(0L);
|
storedFile.setSize(0L);
|
||||||
storedFile.setDirectory(true);
|
storedFile.setDirectory(true);
|
||||||
@@ -153,7 +165,6 @@ public class FileService {
|
|||||||
storedFile.setUser(user);
|
storedFile.setUser(user);
|
||||||
storedFile.setFilename(directoryName);
|
storedFile.setFilename(directoryName);
|
||||||
storedFile.setPath("/");
|
storedFile.setPath("/");
|
||||||
storedFile.setStorageName(directoryName);
|
|
||||||
storedFile.setContentType("directory");
|
storedFile.setContentType("directory");
|
||||||
storedFile.setSize(0L);
|
storedFile.setSize(0L);
|
||||||
storedFile.setDirectory(true);
|
storedFile.setDirectory(true);
|
||||||
@@ -164,17 +175,20 @@ public class FileService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(User user, Long fileId) {
|
public void delete(User user, Long fileId) {
|
||||||
StoredFile storedFile = getOwnedFile(user, fileId, "删除");
|
StoredFile storedFile = getOwnedFile(user, fileId, "删除");
|
||||||
|
List<StoredFile> filesToDelete = new ArrayList<>();
|
||||||
if (storedFile.isDirectory()) {
|
if (storedFile.isDirectory()) {
|
||||||
String logicalPath = buildLogicalPath(storedFile);
|
String logicalPath = buildLogicalPath(storedFile);
|
||||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), logicalPath);
|
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()) {
|
if (!descendants.isEmpty()) {
|
||||||
storedFileRepository.deleteAll(descendants);
|
storedFileRepository.deleteAll(descendants);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileContentStorage.deleteFile(user.getId(), storedFile.getPath(), storedFile.getStorageName());
|
filesToDelete.add(storedFile);
|
||||||
}
|
}
|
||||||
|
List<FileBlob> blobsToDelete = collectBlobsToDelete(filesToDelete);
|
||||||
storedFileRepository.delete(storedFile);
|
storedFileRepository.delete(storedFile);
|
||||||
|
deleteBlobs(blobsToDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -195,7 +209,6 @@ public class FileService {
|
|||||||
: storedFile.getPath() + "/" + sanitizedFilename;
|
: storedFile.getPath() + "/" + sanitizedFilename;
|
||||||
|
|
||||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
|
||||||
for (StoredFile descendant : descendants) {
|
for (StoredFile descendant : descendants) {
|
||||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||||
descendant.setPath(newLogicalPath);
|
descendant.setPath(newLogicalPath);
|
||||||
@@ -207,12 +220,9 @@ public class FileService {
|
|||||||
if (!descendants.isEmpty()) {
|
if (!descendants.isEmpty()) {
|
||||||
storedFileRepository.saveAll(descendants);
|
storedFileRepository.saveAll(descendants);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
fileContentStorage.renameFile(user.getId(), storedFile.getPath(), storedFile.getStorageName(), sanitizedFilename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storedFile.setFilename(sanitizedFilename);
|
storedFile.setFilename(sanitizedFilename);
|
||||||
storedFile.setStorageName(sanitizedFilename);
|
|
||||||
return toResponse(storedFileRepository.save(storedFile));
|
return toResponse(storedFileRepository.save(storedFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +249,6 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
|
||||||
fileContentStorage.renameDirectory(user.getId(), oldLogicalPath, newLogicalPath, descendants);
|
|
||||||
for (StoredFile descendant : descendants) {
|
for (StoredFile descendant : descendants) {
|
||||||
if (descendant.getPath().equals(oldLogicalPath)) {
|
if (descendant.getPath().equals(oldLogicalPath)) {
|
||||||
descendant.setPath(newLogicalPath);
|
descendant.setPath(newLogicalPath);
|
||||||
@@ -251,8 +260,6 @@ public class FileService {
|
|||||||
if (!descendants.isEmpty()) {
|
if (!descendants.isEmpty()) {
|
||||||
storedFileRepository.saveAll(descendants);
|
storedFileRepository.saveAll(descendants);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
fileContentStorage.moveFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storedFile.setPath(normalizedTargetPath);
|
storedFile.setPath(normalizedTargetPath);
|
||||||
@@ -270,8 +277,7 @@ public class FileService {
|
|||||||
|
|
||||||
if (!storedFile.isDirectory()) {
|
if (!storedFile.isDirectory()) {
|
||||||
ensureWithinStorageQuota(user, storedFile.getSize());
|
ensureWithinStorageQuota(user, storedFile.getSize());
|
||||||
fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
|
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, user, normalizedTargetPath)));
|
||||||
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String oldLogicalPath = buildLogicalPath(storedFile);
|
String oldLogicalPath = buildLogicalPath(storedFile);
|
||||||
@@ -288,8 +294,7 @@ public class FileService {
|
|||||||
ensureWithinStorageQuota(user, additionalBytes);
|
ensureWithinStorageQuota(user, additionalBytes);
|
||||||
List<StoredFile> copiedEntries = new ArrayList<>();
|
List<StoredFile> copiedEntries = new ArrayList<>();
|
||||||
|
|
||||||
fileContentStorage.ensureDirectory(user.getId(), newLogicalPath);
|
StoredFile copiedRoot = copyStoredFile(storedFile, user, normalizedTargetPath);
|
||||||
StoredFile copiedRoot = copyStoredFile(storedFile, normalizedTargetPath);
|
|
||||||
copiedEntries.add(copiedRoot);
|
copiedEntries.add(copiedRoot);
|
||||||
|
|
||||||
descendants.stream()
|
descendants.stream()
|
||||||
@@ -303,12 +308,7 @@ public class FileService {
|
|||||||
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
throw new BusinessException(ErrorCode.UNKNOWN, "目标目录已存在同名文件");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descendant.isDirectory()) {
|
copiedEntries.add(copyStoredFile(descendant, user, copiedPath));
|
||||||
fileContentStorage.ensureDirectory(user.getId(), buildTargetLogicalPath(copiedPath, descendant.getFilename()));
|
|
||||||
} else {
|
|
||||||
fileContentStorage.copyFile(user.getId(), descendant.getPath(), copiedPath, descendant.getStorageName());
|
|
||||||
}
|
|
||||||
copiedEntries.add(copyStoredFile(descendant, copiedPath));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
StoredFile savedRoot = null;
|
StoredFile savedRoot = null;
|
||||||
@@ -329,10 +329,8 @@ public class FileService {
|
|||||||
|
|
||||||
if (fileContentStorage.supportsDirectDownload()) {
|
if (fileContentStorage.supportsDirectDownload()) {
|
||||||
return ResponseEntity.status(302)
|
return ResponseEntity.status(302)
|
||||||
.location(URI.create(fileContentStorage.createDownloadUrl(
|
.location(URI.create(fileContentStorage.createBlobDownloadUrl(
|
||||||
user.getId(),
|
getRequiredBlob(storedFile).getObjectKey(),
|
||||||
storedFile.getPath(),
|
|
||||||
storedFile.getStorageName(),
|
|
||||||
storedFile.getFilename())))
|
storedFile.getFilename())))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -342,7 +340,7 @@ public class FileService {
|
|||||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
|
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
|
||||||
.contentType(MediaType.parseMediaType(
|
.contentType(MediaType.parseMediaType(
|
||||||
storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType()))
|
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) {
|
public DownloadUrlResponse getDownloadUrl(User user, Long fileId) {
|
||||||
@@ -353,10 +351,8 @@ public class FileService {
|
|||||||
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
||||||
|
|
||||||
if (fileContentStorage.supportsDirectDownload()) {
|
if (fileContentStorage.supportsDirectDownload()) {
|
||||||
return new DownloadUrlResponse(fileContentStorage.createDownloadUrl(
|
return new DownloadUrlResponse(fileContentStorage.createBlobDownloadUrl(
|
||||||
user.getId(),
|
getRequiredBlob(storedFile).getObjectKey(),
|
||||||
storedFile.getPath(),
|
|
||||||
storedFile.getStorageName(),
|
|
||||||
storedFile.getFilename()
|
storedFile.getFilename()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -407,19 +403,13 @@ public class FileService {
|
|||||||
if (sourceFile.isDirectory()) {
|
if (sourceFile.isDirectory()) {
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
|
throw new BusinessException(ErrorCode.UNKNOWN, "目录暂不支持导入");
|
||||||
}
|
}
|
||||||
|
return importReferencedBlob(
|
||||||
byte[] content = fileContentStorage.readFile(
|
|
||||||
sourceFile.getUser().getId(),
|
|
||||||
sourceFile.getPath(),
|
|
||||||
sourceFile.getStorageName()
|
|
||||||
);
|
|
||||||
return importExternalFile(
|
|
||||||
recipient,
|
recipient,
|
||||||
path,
|
path,
|
||||||
sourceFile.getFilename(),
|
sourceFile.getFilename(),
|
||||||
sourceFile.getContentType(),
|
sourceFile.getContentType(),
|
||||||
sourceFile.getSize(),
|
sourceFile.getSize(),
|
||||||
content
|
getRequiredBlob(sourceFile)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,22 +424,20 @@ public class FileService {
|
|||||||
String normalizedFilename = normalizeLeafName(filename);
|
String normalizedFilename = normalizeLeafName(filename);
|
||||||
validateUpload(recipient, normalizedPath, normalizedFilename, size);
|
validateUpload(recipient, normalizedPath, normalizedFilename, size);
|
||||||
ensureDirectoryHierarchy(recipient, normalizedPath);
|
ensureDirectoryHierarchy(recipient, normalizedPath);
|
||||||
fileContentStorage.storeImportedFile(
|
String objectKey = createBlobObjectKey();
|
||||||
recipient.getId(),
|
return executeAfterBlobStored(objectKey, () -> {
|
||||||
normalizedPath,
|
fileContentStorage.storeBlob(objectKey, contentType, content);
|
||||||
normalizedFilename,
|
FileBlob blob = createAndSaveBlob(objectKey, contentType, size);
|
||||||
contentType,
|
|
||||||
content
|
|
||||||
);
|
|
||||||
|
|
||||||
return saveFileMetadata(
|
return saveFileMetadata(
|
||||||
recipient,
|
recipient,
|
||||||
normalizedPath,
|
normalizedPath,
|
||||||
normalizedFilename,
|
normalizedFilename,
|
||||||
normalizedFilename,
|
contentType,
|
||||||
contentType,
|
size,
|
||||||
size
|
blob
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
private ResponseEntity<byte[]> downloadDirectory(User user, StoredFile directory) {
|
||||||
@@ -475,7 +463,7 @@ public class FileService {
|
|||||||
|
|
||||||
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName);
|
ensureParentDirectoryEntries(zipOutputStream, createdEntries, entryName);
|
||||||
writeFileEntry(zipOutputStream, createdEntries, entryName,
|
writeFileEntry(zipOutputStream, createdEntries, entryName,
|
||||||
fileContentStorage.readFile(user.getId(), descendant.getPath(), descendant.getStorageName()));
|
fileContentStorage.readBlob(getRequiredBlob(descendant).getObjectKey()));
|
||||||
}
|
}
|
||||||
zipOutputStream.finish();
|
zipOutputStream.finish();
|
||||||
archiveBytes = outputStream.toByteArray();
|
archiveBytes = outputStream.toByteArray();
|
||||||
@@ -493,17 +481,17 @@ public class FileService {
|
|||||||
private FileMetadataResponse saveFileMetadata(User user,
|
private FileMetadataResponse saveFileMetadata(User user,
|
||||||
String normalizedPath,
|
String normalizedPath,
|
||||||
String filename,
|
String filename,
|
||||||
String storageName,
|
|
||||||
String contentType,
|
String contentType,
|
||||||
long size) {
|
long size,
|
||||||
|
FileBlob blob) {
|
||||||
StoredFile storedFile = new StoredFile();
|
StoredFile storedFile = new StoredFile();
|
||||||
storedFile.setUser(user);
|
storedFile.setUser(user);
|
||||||
storedFile.setFilename(filename);
|
storedFile.setFilename(filename);
|
||||||
storedFile.setPath(normalizedPath);
|
storedFile.setPath(normalizedPath);
|
||||||
storedFile.setStorageName(storageName);
|
|
||||||
storedFile.setContentType(contentType);
|
storedFile.setContentType(contentType);
|
||||||
storedFile.setSize(size);
|
storedFile.setSize(size);
|
||||||
storedFile.setDirectory(false);
|
storedFile.setDirectory(false);
|
||||||
|
storedFile.setBlob(blob);
|
||||||
return toResponse(storedFileRepository.save(storedFile));
|
return toResponse(storedFileRepository.save(storedFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +501,7 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private StoredFile getOwnedFile(User user, Long fileId, String action) {
|
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, "文件不存在"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||||
if (!storedFile.getUser().getId().equals(user.getId())) {
|
if (!storedFile.getUser().getId().equals(user.getId())) {
|
||||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件");
|
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限" + action + "该文件");
|
||||||
@@ -565,7 +553,6 @@ public class FileService {
|
|||||||
storedFile.setUser(user);
|
storedFile.setUser(user);
|
||||||
storedFile.setFilename(segment);
|
storedFile.setFilename(segment);
|
||||||
storedFile.setPath(currentPath);
|
storedFile.setPath(currentPath);
|
||||||
storedFile.setStorageName(segment);
|
|
||||||
storedFile.setContentType("directory");
|
storedFile.setContentType("directory");
|
||||||
storedFile.setSize(0L);
|
storedFile.setSize(0L);
|
||||||
storedFile.setDirectory(true);
|
storedFile.setDirectory(true);
|
||||||
@@ -658,15 +645,15 @@ public class FileService {
|
|||||||
return newLogicalPath + currentPath.substring(oldLogicalPath.length());
|
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();
|
StoredFile copiedFile = new StoredFile();
|
||||||
copiedFile.setUser(source.getUser());
|
copiedFile.setUser(owner);
|
||||||
copiedFile.setFilename(source.getFilename());
|
copiedFile.setFilename(source.getFilename());
|
||||||
copiedFile.setPath(nextPath);
|
copiedFile.setPath(nextPath);
|
||||||
copiedFile.setStorageName(source.getStorageName());
|
|
||||||
copiedFile.setContentType(source.getContentType());
|
copiedFile.setContentType(source.getContentType());
|
||||||
copiedFile.setSize(source.getSize());
|
copiedFile.setSize(source.getSize());
|
||||||
copiedFile.setDirectory(source.isDirectory());
|
copiedFile.setDirectory(source.isDirectory());
|
||||||
|
copiedFile.setBlob(source.getBlob());
|
||||||
return copiedFile;
|
return copiedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,4 +704,108 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
return cleaned;
|
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)
|
@Column(nullable = false, length = 512)
|
||||||
private String path;
|
private String path;
|
||||||
|
|
||||||
@Column(name = "storage_name", nullable = false, length = 255)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
private String storageName;
|
@JoinColumn(name = "blob_id")
|
||||||
|
private FileBlob blob;
|
||||||
|
|
||||||
|
@Column(name = "storage_name", length = 255)
|
||||||
|
private String legacyStorageName;
|
||||||
|
|
||||||
@Column(name = "content_type", length = 255)
|
@Column(name = "content_type", length = 255)
|
||||||
private String contentType;
|
private String contentType;
|
||||||
@@ -90,12 +94,20 @@ public class StoredFile {
|
|||||||
this.path = path;
|
this.path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStorageName() {
|
public FileBlob getBlob() {
|
||||||
return storageName;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setStorageName(String storageName) {
|
public void setBlob(FileBlob blob) {
|
||||||
this.storageName = storageName;
|
this.blob = blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLegacyStorageName() {
|
||||||
|
return legacyStorageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLegacyStorageName(String legacyStorageName) {
|
||||||
|
this.legacyStorageName = legacyStorageName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getContentType() {
|
public String getContentType() {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||||
|
|
||||||
@EntityGraph(attributePaths = "user")
|
@EntityGraph(attributePaths = {"user", "blob"})
|
||||||
Page<StoredFile> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
Page<StoredFile> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||||
|
|
||||||
@EntityGraph(attributePaths = "user")
|
@EntityGraph(attributePaths = {"user", "blob"})
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
select f from StoredFile f
|
||||||
join f.user u
|
join f.user u
|
||||||
@@ -47,6 +47,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@Param("path") String path,
|
@Param("path") String path,
|
||||||
@Param("filename") String filename);
|
@Param("filename") String filename);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = "blob")
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
select f from StoredFile f
|
||||||
where f.user.id = :userId and f.path = :path
|
where f.user.id = :userId and f.path = :path
|
||||||
@@ -56,6 +57,7 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
|||||||
@Param("path") String path,
|
@Param("path") String path,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = "blob")
|
||||||
@Query("""
|
@Query("""
|
||||||
select f from StoredFile f
|
select f from StoredFile f
|
||||||
where f.user.id = :userId and (f.path = :path or f.path like concat(:path, '/%'))
|
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();
|
long sumAllFileSize();
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = "blob")
|
||||||
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
|
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 = "通过取件码查找快传会话")
|
@Operation(summary = "通过取件码查找快传会话")
|
||||||
@GetMapping("/sessions/lookup")
|
@GetMapping("/sessions/lookup")
|
||||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@AuthenticationPrincipal UserDetails userDetails,
|
public ApiResponse<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
|
||||||
@RequestParam String pickupCode) {
|
return ApiResponse.success(transferService.lookupSession(pickupCode));
|
||||||
return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "加入快传会话")
|
@Operation(summary = "加入快传会话")
|
||||||
@PostMapping("/sessions/{sessionId}/join")
|
@PostMapping("/sessions/{sessionId}/join")
|
||||||
public ApiResponse<TransferSessionResponse> joinSession(@AuthenticationPrincipal UserDetails userDetails,
|
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
|
||||||
@PathVariable String sessionId) {
|
return ApiResponse.success(transferService.joinSession(sessionId));
|
||||||
return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "查看当前用户的离线快传列表")
|
@Operation(summary = "查看当前用户的离线快传列表")
|
||||||
@@ -80,10 +78,9 @@ public class TransferController {
|
|||||||
|
|
||||||
@Operation(summary = "下载离线快传文件")
|
@Operation(summary = "下载离线快传文件")
|
||||||
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
|
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
|
||||||
public ResponseEntity<?> downloadOfflineFile(@AuthenticationPrincipal UserDetails userDetails,
|
public ResponseEntity<?> downloadOfflineFile(@PathVariable String sessionId,
|
||||||
@PathVariable String sessionId,
|
|
||||||
@PathVariable String fileId) {
|
@PathVariable String fileId) {
|
||||||
return transferService.downloadOfflineFile(userDetails != null, sessionId, fileId);
|
return transferService.downloadOfflineFile(sessionId, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "把离线快传文件存入网盘")
|
@Operation(summary = "把离线快传文件存入网盘")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public class TransferService {
|
|||||||
return createOnlineSession(request);
|
return createOnlineSession(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LookupTransferSessionResponse lookupSession(boolean authenticated, String pickupCode) {
|
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
||||||
|
|
||||||
@@ -78,12 +78,11 @@ public class TransferService {
|
|||||||
|
|
||||||
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode)
|
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
|
||||||
validateOfflineReadySession(offlineSession, "取件码不存在或已失效");
|
validateOfflineReadySession(offlineSession, "取件码不存在或已失效");
|
||||||
return toLookupResponse(offlineSession);
|
return toLookupResponse(offlineSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TransferSessionResponse joinSession(boolean authenticated, String sessionId) {
|
public TransferSessionResponse joinSession(String sessionId) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
|
|
||||||
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
|
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
|
||||||
@@ -98,7 +97,6 @@ public class TransferService {
|
|||||||
|
|
||||||
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"));
|
||||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
|
||||||
validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效");
|
validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效");
|
||||||
return toSessionResponse(offlineSession);
|
return toSessionResponse(offlineSession);
|
||||||
}
|
}
|
||||||
@@ -171,9 +169,8 @@ public class TransferService {
|
|||||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
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();
|
pruneExpiredSessions();
|
||||||
ensureAuthenticatedForOfflineTransfer(authenticated);
|
|
||||||
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
||||||
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
||||||
ensureOfflineFileUploaded(file);
|
ensureOfflineFileUploaded(file);
|
||||||
@@ -372,12 +369,6 @@ public class TransferService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureAuthenticatedForOfflineTransfer(boolean authenticated) {
|
|
||||||
if (!authenticated) {
|
|
||||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) {
|
private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) {
|
||||||
if (session.isExpired(Instant.now())) {
|
if (session.isExpired(Instant.now())) {
|
||||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage);
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ app:
|
|||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:3000
|
- http://localhost:3000
|
||||||
- http://127.0.0.1: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://yoyuzh.xyz
|
||||||
- https://www.yoyuzh.xyz
|
- https://www.yoyuzh.xyz
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.yoyuzh.PortalBackendApplication;
|
|||||||
import com.yoyuzh.admin.AdminMetricsStateRepository;
|
import com.yoyuzh.admin.AdminMetricsStateRepository;
|
||||||
import com.yoyuzh.auth.User;
|
import com.yoyuzh.auth.User;
|
||||||
import com.yoyuzh.auth.UserRepository;
|
import com.yoyuzh.auth.UserRepository;
|
||||||
|
import com.yoyuzh.files.FileBlob;
|
||||||
|
import com.yoyuzh.files.FileBlobRepository;
|
||||||
import com.yoyuzh.files.StoredFile;
|
import com.yoyuzh.files.StoredFile;
|
||||||
import com.yoyuzh.files.StoredFileRepository;
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
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 org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||||
@@ -56,9 +59,13 @@ class AdminControllerIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private FileBlobRepository fileBlobRepository;
|
||||||
|
@Autowired
|
||||||
private OfflineTransferSessionRepository offlineTransferSessionRepository;
|
private OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||||
|
@Autowired
|
||||||
|
private AdminMetricsService adminMetricsService;
|
||||||
|
|
||||||
private User portalUser;
|
private User portalUser;
|
||||||
private User secondaryUser;
|
private User secondaryUser;
|
||||||
@@ -69,6 +76,7 @@ class AdminControllerIntegrationTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
offlineTransferSessionRepository.deleteAll();
|
offlineTransferSessionRepository.deleteAll();
|
||||||
storedFileRepository.deleteAll();
|
storedFileRepository.deleteAll();
|
||||||
|
fileBlobRepository.deleteAll();
|
||||||
userRepository.deleteAll();
|
userRepository.deleteAll();
|
||||||
adminMetricsStateRepository.deleteAll();
|
adminMetricsStateRepository.deleteAll();
|
||||||
|
|
||||||
@@ -88,33 +96,47 @@ class AdminControllerIntegrationTest {
|
|||||||
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
||||||
secondaryUser = userRepository.save(secondaryUser);
|
secondaryUser = userRepository.save(secondaryUser);
|
||||||
|
|
||||||
|
FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L);
|
||||||
storedFile = new StoredFile();
|
storedFile = new StoredFile();
|
||||||
storedFile.setUser(portalUser);
|
storedFile.setUser(portalUser);
|
||||||
storedFile.setFilename("report.pdf");
|
storedFile.setFilename("report.pdf");
|
||||||
storedFile.setPath("/");
|
storedFile.setPath("/");
|
||||||
storedFile.setStorageName("report.pdf");
|
|
||||||
storedFile.setContentType("application/pdf");
|
storedFile.setContentType("application/pdf");
|
||||||
storedFile.setSize(1024L);
|
storedFile.setSize(1024L);
|
||||||
storedFile.setDirectory(false);
|
storedFile.setDirectory(false);
|
||||||
|
storedFile.setBlob(reportBlob);
|
||||||
storedFile.setCreatedAt(LocalDateTime.now());
|
storedFile.setCreatedAt(LocalDateTime.now());
|
||||||
storedFile = storedFileRepository.save(storedFile);
|
storedFile = storedFileRepository.save(storedFile);
|
||||||
|
|
||||||
|
FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L);
|
||||||
secondaryFile = new StoredFile();
|
secondaryFile = new StoredFile();
|
||||||
secondaryFile.setUser(secondaryUser);
|
secondaryFile.setUser(secondaryUser);
|
||||||
secondaryFile.setFilename("notes.txt");
|
secondaryFile.setFilename("notes.txt");
|
||||||
secondaryFile.setPath("/docs");
|
secondaryFile.setPath("/docs");
|
||||||
secondaryFile.setStorageName("notes.txt");
|
|
||||||
secondaryFile.setContentType("text/plain");
|
secondaryFile.setContentType("text/plain");
|
||||||
secondaryFile.setSize(256L);
|
secondaryFile.setSize(256L);
|
||||||
secondaryFile.setDirectory(false);
|
secondaryFile.setDirectory(false);
|
||||||
|
secondaryFile.setBlob(notesBlob);
|
||||||
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
|
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
|
||||||
secondaryFile = storedFileRepository.save(secondaryFile);
|
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
|
@Test
|
||||||
@WithMockUser(username = "admin")
|
@WithMockUser(username = "admin")
|
||||||
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
|
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
|
||||||
int currentHour = LocalTime.now().getHour();
|
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"))
|
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -134,13 +156,18 @@ class AdminControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.data.totalStorageBytes").value(1280L))
|
.andExpect(jsonPath("$.data.totalStorageBytes").value(1280L))
|
||||||
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L))
|
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L))
|
||||||
.andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1)))
|
.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 + "].hour").value(currentHour))
|
||||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour)))
|
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour)))
|
||||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1)))
|
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1)))
|
||||||
.andExpect(jsonPath("$.data.transferUsageBytes").value(0L))
|
.andExpect(jsonPath("$.data.transferUsageBytes").value(0L))
|
||||||
.andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L))
|
.andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L))
|
||||||
.andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber())
|
.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());
|
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -22,12 +24,18 @@ class AdminMetricsServiceTest {
|
|||||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||||
|
@Mock
|
||||||
|
private AdminDailyActiveUserRepository adminDailyActiveUserRepository;
|
||||||
|
|
||||||
private AdminMetricsService adminMetricsService;
|
private AdminMetricsService adminMetricsService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository);
|
adminMetricsService = new AdminMetricsService(
|
||||||
|
adminMetricsStateRepository,
|
||||||
|
adminRequestTimelinePointRepository,
|
||||||
|
adminDailyActiveUserRepository
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -41,15 +49,21 @@ class AdminMetricsServiceTest {
|
|||||||
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state));
|
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state));
|
||||||
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of());
|
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();
|
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
|
||||||
|
|
||||||
assertThat(snapshot.requestCount()).isZero();
|
assertThat(snapshot.requestCount()).isZero();
|
||||||
assertThat(state.getRequestCount()).isZero();
|
assertThat(state.getRequestCount()).isZero();
|
||||||
assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now());
|
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.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(adminMetricsStateRepository).save(state);
|
||||||
|
verify(adminDailyActiveUserRepository).deleteAllByMetricDateBefore(LocalDate.now().minusDays(6));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -76,4 +90,46 @@ class AdminMetricsServiceTest {
|
|||||||
verify(adminMetricsStateRepository).save(state);
|
verify(adminMetricsStateRepository).save(state);
|
||||||
verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class));
|
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.auth.UserRole;
|
||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.PageResponse;
|
import com.yoyuzh.common.PageResponse;
|
||||||
|
import com.yoyuzh.files.FileBlobRepository;
|
||||||
import com.yoyuzh.files.FileService;
|
import com.yoyuzh.files.FileService;
|
||||||
import com.yoyuzh.files.StoredFile;
|
import com.yoyuzh.files.StoredFile;
|
||||||
import com.yoyuzh.files.StoredFileRepository;
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
@@ -42,6 +43,8 @@ class AdminServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
@Mock
|
@Mock
|
||||||
|
private FileBlobRepository fileBlobRepository;
|
||||||
|
@Mock
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
@Mock
|
@Mock
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
@@ -59,7 +62,7 @@ class AdminServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
adminService = new AdminService(
|
adminService = new AdminService(
|
||||||
userRepository, storedFileRepository, fileService,
|
userRepository, storedFileRepository, fileBlobRepository, fileService,
|
||||||
passwordEncoder, refreshTokenService, registrationInviteService,
|
passwordEncoder, refreshTokenService, registrationInviteService,
|
||||||
offlineTransferSessionRepository, adminMetricsService);
|
offlineTransferSessionRepository, adminMetricsService);
|
||||||
}
|
}
|
||||||
@@ -70,12 +73,16 @@ class AdminServiceTest {
|
|||||||
void shouldReturnSummaryWithCountsAndInviteCode() {
|
void shouldReturnSummaryWithCountsAndInviteCode() {
|
||||||
when(userRepository.count()).thenReturn(5L);
|
when(userRepository.count()).thenReturn(5L);
|
||||||
when(storedFileRepository.count()).thenReturn(42L);
|
when(storedFileRepository.count()).thenReturn(42L);
|
||||||
when(storedFileRepository.sumAllFileSize()).thenReturn(8192L);
|
when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L);
|
||||||
when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot(
|
when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot(
|
||||||
0L,
|
0L,
|
||||||
0L,
|
0L,
|
||||||
0L,
|
0L,
|
||||||
20L * 1024 * 1024 * 1024,
|
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(
|
List.of(
|
||||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||||
@@ -94,6 +101,10 @@ class AdminServiceTest {
|
|||||||
assertThat(summary.transferUsageBytes()).isZero();
|
assertThat(summary.transferUsageBytes()).isZero();
|
||||||
assertThat(summary.offlineTransferStorageBytes()).isZero();
|
assertThat(summary.offlineTransferStorageBytes()).isZero();
|
||||||
assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L);
|
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(
|
assertThat(summary.requestTimeline()).containsExactly(
|
||||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
package com.yoyuzh.auth;
|
package com.yoyuzh.auth;
|
||||||
|
|
||||||
import com.yoyuzh.config.FileStorageProperties;
|
|
||||||
import com.yoyuzh.files.FileService;
|
import com.yoyuzh.files.FileService;
|
||||||
import com.yoyuzh.files.StoredFileRepository;
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -39,15 +36,9 @@ class DevBootstrapDataInitializerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FileStorageProperties fileStorageProperties;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private DevBootstrapDataInitializer initializer;
|
private DevBootstrapDataInitializer initializer;
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldCreateInitialDevUsersWhenMissing() throws Exception {
|
void shouldCreateInitialDevUsersWhenMissing() throws Exception {
|
||||||
when(userRepository.findByUsername("portal-demo")).thenReturn(Optional.empty());
|
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("study123456")).thenReturn("encoded-study-password");
|
||||||
when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password");
|
when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password");
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false);
|
||||||
when(fileStorageProperties.getRootDir()).thenReturn(tempDir.toString());
|
|
||||||
List<User> savedUsers = new ArrayList<>();
|
List<User> savedUsers = new ArrayList<>();
|
||||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
||||||
User user = invocation.getArgument(0);
|
User user = invocation.getArgument(0);
|
||||||
@@ -71,9 +61,9 @@ class DevBootstrapDataInitializerTest {
|
|||||||
|
|
||||||
verify(userRepository, times(3)).save(any(User.class));
|
verify(userRepository, times(3)).save(any(User.class));
|
||||||
verify(fileService, times(3)).ensureDefaultDirectories(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)
|
org.assertj.core.api.Assertions.assertThat(savedUsers)
|
||||||
.extracting(User::getUsername)
|
.extracting(User::getUsername)
|
||||||
.containsExactly("portal-demo", "portal-study", "portal-design");
|
.containsExactly("portal-demo", "portal-study", "portal-design");
|
||||||
verify(storedFileRepository, times(9)).save(any());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yoyuzh.config;
|
package com.yoyuzh.config;
|
||||||
|
|
||||||
|
import com.yoyuzh.admin.AdminMetricsService;
|
||||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||||
import com.yoyuzh.auth.JwtTokenProvider;
|
import com.yoyuzh.auth.JwtTokenProvider;
|
||||||
import com.yoyuzh.auth.User;
|
import com.yoyuzh.auth.User;
|
||||||
@@ -33,13 +34,15 @@ class JwtAuthenticationFilterTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private CustomUserDetailsService userDetailsService;
|
private CustomUserDetailsService userDetailsService;
|
||||||
@Mock
|
@Mock
|
||||||
|
private AdminMetricsService adminMetricsService;
|
||||||
|
@Mock
|
||||||
private FilterChain filterChain;
|
private FilterChain filterChain;
|
||||||
|
|
||||||
private JwtAuthenticationFilter filter;
|
private JwtAuthenticationFilter filter;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService);
|
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService, adminMetricsService);
|
||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class JwtAuthenticationFilterTest {
|
|||||||
verify(filterChain).doFilter(request, response);
|
verify(filterChain).doFilter(request, response);
|
||||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
|
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
|
||||||
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice");
|
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice");
|
||||||
|
verify(adminMetricsService).recordUserOnline(1L, "alice");
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createDomainUser(String username, String sessionId) {
|
private User createDomainUser(String username, String sessionId) {
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ class SecurityConfigTest {
|
|||||||
CorsProperties corsProperties = new CorsProperties();
|
CorsProperties corsProperties = new CorsProperties();
|
||||||
|
|
||||||
assertThat(corsProperties.getAllowedOrigins())
|
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
|
@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
|
@Mock
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
@Mock
|
@Mock
|
||||||
|
private FileBlobRepository fileBlobRepository;
|
||||||
|
@Mock
|
||||||
private FileContentStorage fileContentStorage;
|
private FileContentStorage fileContentStorage;
|
||||||
@Mock
|
@Mock
|
||||||
private FileShareLinkRepository fileShareLinkRepository;
|
private FileShareLinkRepository fileShareLinkRepository;
|
||||||
@@ -44,7 +46,14 @@ class FileServiceEdgeCaseTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
FileStorageProperties properties = new FileStorageProperties();
|
FileStorageProperties properties = new FileStorageProperties();
|
||||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
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 ---
|
// --- normalizeDirectoryPath edge cases ---
|
||||||
@@ -131,9 +140,9 @@ class FileServiceEdgeCaseTest {
|
|||||||
void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() {
|
void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() {
|
||||||
User user = createUser(1L);
|
User user = createUser(1L);
|
||||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
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.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");
|
.thenReturn("https://cdn.example.com/notes.txt");
|
||||||
|
|
||||||
ResponseEntity<?> response = fileService.download(user, 10L);
|
ResponseEntity<?> response = fileService.download(user, 10L);
|
||||||
@@ -149,7 +158,7 @@ class FileServiceEdgeCaseTest {
|
|||||||
void shouldRejectCreatingShareLinkForDirectory() {
|
void shouldRejectCreatingShareLinkForDirectory() {
|
||||||
User user = createUser(1L);
|
User user = createUser(1L);
|
||||||
StoredFile directory = createDirectory(10L, user, "/", "docs");
|
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))
|
assertThatThrownBy(() -> fileService.createShareLink(user, 10L))
|
||||||
.isInstanceOf(BusinessException.class)
|
.isInstanceOf(BusinessException.class)
|
||||||
@@ -162,7 +171,7 @@ class FileServiceEdgeCaseTest {
|
|||||||
void shouldRejectDownloadUrlForDirectory() {
|
void shouldRejectDownloadUrlForDirectory() {
|
||||||
User user = createUser(1L);
|
User user = createUser(1L);
|
||||||
StoredFile directory = createDirectory(10L, user, "/", "docs");
|
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))
|
assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L))
|
||||||
.isInstanceOf(BusinessException.class)
|
.isInstanceOf(BusinessException.class)
|
||||||
@@ -211,7 +220,7 @@ class FileServiceEdgeCaseTest {
|
|||||||
void shouldReturnUnchangedFileWhenRenameToSameName() {
|
void shouldReturnUnchangedFileWhenRenameToSameName() {
|
||||||
User user = createUser(1L);
|
User user = createUser(1L);
|
||||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
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");
|
FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt");
|
||||||
|
|
||||||
@@ -237,9 +246,10 @@ class FileServiceEdgeCaseTest {
|
|||||||
file.setUser(user);
|
file.setUser(user);
|
||||||
file.setFilename(filename);
|
file.setFilename(filename);
|
||||||
file.setPath(path);
|
file.setPath(path);
|
||||||
file.setStorageName(filename);
|
|
||||||
file.setSize(5L);
|
file.setSize(5L);
|
||||||
file.setDirectory(false);
|
file.setDirectory(false);
|
||||||
|
file.setContentType("text/plain");
|
||||||
|
file.setBlob(createBlob(id, "blobs/blob-" + id, 5L, "text/plain"));
|
||||||
file.setCreatedAt(LocalDateTime.now());
|
file.setCreatedAt(LocalDateTime.now());
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
@@ -249,6 +259,17 @@ class FileServiceEdgeCaseTest {
|
|||||||
dir.setDirectory(true);
|
dir.setDirectory(true);
|
||||||
dir.setContentType("directory");
|
dir.setContentType("directory");
|
||||||
dir.setSize(0L);
|
dir.setSize(0L);
|
||||||
|
dir.setBlob(null);
|
||||||
return dir;
|
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.AdditionalMatchers.aryEq;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -42,6 +43,9 @@ class FileServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileBlobRepository fileBlobRepository;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private FileContentStorage fileContentStorage;
|
private FileContentStorage fileContentStorage;
|
||||||
|
|
||||||
@@ -56,7 +60,14 @@ class FileServiceTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
FileStorageProperties properties = new FileStorageProperties();
|
FileStorageProperties properties = new FileStorageProperties();
|
||||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
|
fileService = new FileService(
|
||||||
|
storedFileRepository,
|
||||||
|
fileBlobRepository,
|
||||||
|
fileContentStorage,
|
||||||
|
fileShareLinkRepository,
|
||||||
|
adminMetricsService,
|
||||||
|
properties
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,6 +76,11 @@ class FileServiceTest {
|
|||||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
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 -> {
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||||
StoredFile file = invocation.getArgument(0);
|
StoredFile file = invocation.getArgument(0);
|
||||||
file.setId(10L);
|
file.setId(10L);
|
||||||
@@ -75,22 +91,28 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(response.id()).isEqualTo(10L);
|
assertThat(response.id()).isEqualTo(10L);
|
||||||
assertThat(response.path()).isEqualTo("/docs");
|
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
|
@Test
|
||||||
void shouldInitiateDirectUploadThroughStorage() {
|
void shouldInitiateDirectUploadThroughStorage() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||||
when(fileContentStorage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
|
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"), "notes.txt"));
|
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "blobs/upload-1"));
|
||||||
|
|
||||||
InitiateUploadResponse response = fileService.initiateUpload(user,
|
InitiateUploadResponse response = fileService.initiateUpload(user,
|
||||||
new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L));
|
new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L));
|
||||||
|
|
||||||
assertThat(response.direct()).isTrue();
|
assertThat(response.direct()).isTrue();
|
||||||
assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com");
|
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
|
@Test
|
||||||
@@ -98,20 +120,25 @@ class FileServiceTest {
|
|||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
long uploadSize = 500L * 1024 * 1024;
|
long uploadSize = 500L * 1024 * 1024;
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false);
|
||||||
when(fileContentStorage.prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize))
|
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(), "movie.zip"));
|
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "blobs/upload-2"));
|
||||||
|
|
||||||
InitiateUploadResponse response = fileService.initiateUpload(user,
|
InitiateUploadResponse response = fileService.initiateUpload(user,
|
||||||
new InitiateUploadRequest("/docs", "movie.zip", "application/zip", uploadSize));
|
new InitiateUploadRequest("/docs", "movie.zip", "application/zip", uploadSize));
|
||||||
|
|
||||||
assertThat(response.direct()).isTrue();
|
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
|
@Test
|
||||||
void shouldCompleteDirectUploadAndPersistMetadata() {
|
void shouldCompleteDirectUploadAndPersistMetadata() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
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 -> {
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||||
StoredFile file = invocation.getArgument(0);
|
StoredFile file = invocation.getArgument(0);
|
||||||
file.setId(11L);
|
file.setId(11L);
|
||||||
@@ -119,10 +146,44 @@ class FileServiceTest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
FileMetadataResponse response = fileService.completeUpload(user,
|
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);
|
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
|
@Test
|
||||||
@@ -131,14 +192,15 @@ class FileServiceTest {
|
|||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).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));
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
fileService.completeUpload(user,
|
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");
|
||||||
verify(fileContentStorage).ensureDirectory(7L, "/projects/site");
|
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));
|
verify(storedFileRepository, times(3)).save(any(StoredFile.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +208,14 @@ class FileServiceTest {
|
|||||||
void shouldRenameFileThroughConfiguredStorage() {
|
void shouldRenameFileThroughConfiguredStorage() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt");
|
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.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed.txt")).thenReturn(false);
|
||||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt");
|
FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt");
|
||||||
|
|
||||||
assertThat(response.filename()).isEqualTo("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
|
@Test
|
||||||
@@ -162,7 +224,7 @@ class FileServiceTest {
|
|||||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||||
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
|
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.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed-archive")).thenReturn(false);
|
||||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
@@ -171,7 +233,7 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(response.filename()).isEqualTo("renamed-archive");
|
assertThat(response.filename()).isEqualTo("renamed-archive");
|
||||||
assertThat(childFile.getPath()).isEqualTo("/docs/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
|
@Test
|
||||||
@@ -179,7 +241,7 @@ class FileServiceTest {
|
|||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
|
||||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
|
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.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory));
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
||||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
@@ -188,7 +250,7 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(response.path()).isEqualTo("/下载");
|
assertThat(response.path()).isEqualTo("/下载");
|
||||||
assertThat(file.getPath()).isEqualTo("/下载");
|
assertThat(file.getPath()).isEqualTo("/下载");
|
||||||
verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt");
|
verify(fileContentStorage, never()).moveFile(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -197,7 +259,7 @@ class FileServiceTest {
|
|||||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
||||||
StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt");
|
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.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory));
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
||||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||||
@@ -209,7 +271,7 @@ class FileServiceTest {
|
|||||||
assertThat(response.path()).isEqualTo("/图片/archive");
|
assertThat(response.path()).isEqualTo("/图片/archive");
|
||||||
assertThat(directory.getPath()).isEqualTo("/图片");
|
assertThat(directory.getPath()).isEqualTo("/图片");
|
||||||
assertThat(childFile.getPath()).isEqualTo("/图片/archive");
|
assertThat(childFile.getPath()).isEqualTo("/图片/archive");
|
||||||
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile));
|
verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -219,7 +281,7 @@ class FileServiceTest {
|
|||||||
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
||||||
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
||||||
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
|
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"))
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||||
.thenReturn(Optional.of(docsDirectory));
|
.thenReturn(Optional.of(docsDirectory));
|
||||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||||
@@ -235,9 +297,10 @@ class FileServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldCopyFileToAnotherDirectory() {
|
void shouldCopyFileToAnotherDirectory() {
|
||||||
User user = createUser(7L);
|
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, "/", "下载");
|
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.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory));
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false);
|
||||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||||
@@ -252,7 +315,8 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(response.id()).isEqualTo(20L);
|
assertThat(response.id()).isEqualTo(20L);
|
||||||
assertThat(response.path()).isEqualTo("/下载");
|
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
|
@Test
|
||||||
@@ -261,9 +325,11 @@ class FileServiceTest {
|
|||||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
||||||
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
|
||||||
StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested");
|
StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested");
|
||||||
StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt");
|
FileBlob childBlob = createBlob(51L, "blobs/blob-archive-1", 5L, "text/plain");
|
||||||
StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt");
|
FileBlob nestedBlob = createBlob(52L, "blobs/blob-archive-2", 5L, "text/plain");
|
||||||
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
|
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.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory));
|
||||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false);
|
||||||
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
||||||
@@ -282,10 +348,7 @@ class FileServiceTest {
|
|||||||
FileMetadataResponse response = fileService.copy(user, 10L, "/图片");
|
FileMetadataResponse response = fileService.copy(user, 10L, "/图片");
|
||||||
|
|
||||||
assertThat(response.path()).isEqualTo("/图片/archive");
|
assertThat(response.path()).isEqualTo("/图片/archive");
|
||||||
verify(fileContentStorage).ensureDirectory(7L, "/图片/archive");
|
verify(fileContentStorage, never()).copyFile(any(), any(), any(), any());
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -295,7 +358,7 @@ class FileServiceTest {
|
|||||||
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
|
||||||
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
|
||||||
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
|
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"))
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
|
||||||
.thenReturn(Optional.of(docsDirectory));
|
.thenReturn(Optional.of(docsDirectory));
|
||||||
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
|
||||||
@@ -313,7 +376,7 @@ class FileServiceTest {
|
|||||||
User owner = createUser(1L);
|
User owner = createUser(1L);
|
||||||
User requester = createUser(2L);
|
User requester = createUser(2L);
|
||||||
StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt");
|
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))
|
assertThatThrownBy(() -> fileService.delete(requester, 100L))
|
||||||
.isInstanceOf(BusinessException.class)
|
.isInstanceOf(BusinessException.class)
|
||||||
@@ -324,18 +387,51 @@ class FileServiceTest {
|
|||||||
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
|
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
|
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.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
|
||||||
|
when(storedFileRepository.countByBlobId(60L)).thenReturn(1L);
|
||||||
|
|
||||||
fileService.delete(user, 10L);
|
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).deleteAll(List.of(childFile));
|
||||||
verify(storedFileRepository).delete(directory);
|
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
|
@Test
|
||||||
void shouldListFilesByPathWithPagination() {
|
void shouldListFilesByPathWithPagination() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
@@ -370,9 +466,9 @@ class FileServiceTest {
|
|||||||
void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() {
|
void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
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.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");
|
.thenReturn("https://download.example.com/file");
|
||||||
|
|
||||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||||
@@ -384,7 +480,7 @@ class FileServiceTest {
|
|||||||
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
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);
|
when(fileContentStorage.supportsDirectDownload()).thenReturn(false);
|
||||||
|
|
||||||
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
|
||||||
@@ -401,12 +497,12 @@ class FileServiceTest {
|
|||||||
StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt");
|
StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt");
|
||||||
StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.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"))
|
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
|
||||||
.thenReturn(List.of(childDirectory, childFile, nestedFile));
|
.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));
|
.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));
|
.thenReturn("world".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
var response = fileService.download(user, 10L);
|
var response = fileService.download(user, 10L);
|
||||||
@@ -430,15 +526,15 @@ class FileServiceTest {
|
|||||||
assertThat(entries).containsEntry("archive/nested/", "");
|
assertThat(entries).containsEntry("archive/nested/", "");
|
||||||
assertThat(entries).containsEntry("archive/notes.txt", "hello");
|
assertThat(entries).containsEntry("archive/notes.txt", "hello");
|
||||||
assertThat(entries).containsEntry("archive/nested/todo.txt", "world");
|
assertThat(entries).containsEntry("archive/nested/todo.txt", "world");
|
||||||
verify(fileContentStorage).readFile(7L, "/docs/archive", "notes.txt");
|
verify(fileContentStorage).readBlob("blobs/blob-12");
|
||||||
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
|
verify(fileContentStorage).readBlob("blobs/blob-13");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldCreateShareLinkForOwnedFile() {
|
void shouldCreateShareLinkForOwnedFile() {
|
||||||
User user = createUser(7L);
|
User user = createUser(7L);
|
||||||
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
|
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 -> {
|
when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> {
|
||||||
FileShareLink shareLink = invocation.getArgument(0);
|
FileShareLink shareLink = invocation.getArgument(0);
|
||||||
shareLink.setId(100L);
|
shareLink.setId(100L);
|
||||||
@@ -457,7 +553,8 @@ class FileServiceTest {
|
|||||||
void shouldImportSharedFileIntoRecipientWorkspace() {
|
void shouldImportSharedFileIntoRecipientWorkspace() {
|
||||||
User owner = createUser(7L);
|
User owner = createUser(7L);
|
||||||
User recipient = createUser(8L);
|
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();
|
FileShareLink shareLink = new FileShareLink();
|
||||||
shareLink.setId(100L);
|
shareLink.setId(100L);
|
||||||
shareLink.setToken("share-token-1");
|
shareLink.setToken("share-token-1");
|
||||||
@@ -471,21 +568,30 @@ class FileServiceTest {
|
|||||||
file.setId(200L);
|
file.setId(200L);
|
||||||
return file;
|
return file;
|
||||||
});
|
});
|
||||||
when(fileContentStorage.readFile(7L, "/docs", "notes.txt"))
|
|
||||||
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载");
|
FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载");
|
||||||
|
|
||||||
assertThat(response.id()).isEqualTo(200L);
|
assertThat(response.id()).isEqualTo(200L);
|
||||||
assertThat(response.path()).isEqualTo("/下载");
|
assertThat(response.path()).isEqualTo("/下载");
|
||||||
assertThat(response.filename()).isEqualTo("notes.txt");
|
assertThat(response.filename()).isEqualTo("notes.txt");
|
||||||
verify(fileContentStorage).storeImportedFile(
|
verify(fileContentStorage, never()).storeImportedFile(any(), any(), any(), any(), any());
|
||||||
eq(8L),
|
verify(fileContentStorage, never()).readFile(any(), any(), any());
|
||||||
eq("/下载"),
|
}
|
||||||
eq("notes.txt"),
|
|
||||||
eq(sourceFile.getContentType()),
|
@Test
|
||||||
aryEq("hello".getBytes(StandardCharsets.UTF_8))
|
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) {
|
private User createUser(Long id) {
|
||||||
@@ -498,7 +604,21 @@ class FileServiceTest {
|
|||||||
return user;
|
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) {
|
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();
|
StoredFile file = new StoredFile();
|
||||||
file.setId(id);
|
file.setId(id);
|
||||||
file.setUser(user);
|
file.setUser(user);
|
||||||
@@ -506,7 +626,8 @@ class FileServiceTest {
|
|||||||
file.setPath(path);
|
file.setPath(path);
|
||||||
file.setSize(5L);
|
file.setSize(5L);
|
||||||
file.setDirectory(false);
|
file.setDirectory(false);
|
||||||
file.setStorageName(filename);
|
file.setContentType("text/plain");
|
||||||
|
file.setBlob(blob);
|
||||||
file.setCreatedAt(LocalDateTime.now());
|
file.setCreatedAt(LocalDateTime.now());
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
@@ -516,6 +637,7 @@ class FileServiceTest {
|
|||||||
directory.setDirectory(true);
|
directory.setDirectory(true);
|
||||||
directory.setContentType("directory");
|
directory.setContentType("directory");
|
||||||
directory.setSize(0L);
|
directory.setSize(0L);
|
||||||
|
directory.setBlob(null);
|
||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDateTime;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
@@ -50,6 +53,8 @@ class FileShareControllerIntegrationTest {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private StoredFileRepository storedFileRepository;
|
private StoredFileRepository storedFileRepository;
|
||||||
|
@Autowired
|
||||||
|
private FileBlobRepository fileBlobRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private FileShareLinkRepository fileShareLinkRepository;
|
private FileShareLinkRepository fileShareLinkRepository;
|
||||||
@@ -58,6 +63,7 @@ class FileShareControllerIntegrationTest {
|
|||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
fileShareLinkRepository.deleteAll();
|
fileShareLinkRepository.deleteAll();
|
||||||
storedFileRepository.deleteAll();
|
storedFileRepository.deleteAll();
|
||||||
|
fileBlobRepository.deleteAll();
|
||||||
userRepository.deleteAll();
|
userRepository.deleteAll();
|
||||||
if (Files.exists(STORAGE_ROOT)) {
|
if (Files.exists(STORAGE_ROOT)) {
|
||||||
try (var paths = Files.walk(STORAGE_ROOT)) {
|
try (var paths = Files.walk(STORAGE_ROOT)) {
|
||||||
@@ -88,19 +94,26 @@ class FileShareControllerIntegrationTest {
|
|||||||
recipient.setCreatedAt(LocalDateTime.now());
|
recipient.setCreatedAt(LocalDateTime.now());
|
||||||
recipient = userRepository.save(recipient);
|
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();
|
StoredFile file = new StoredFile();
|
||||||
file.setUser(owner);
|
file.setUser(owner);
|
||||||
file.setFilename("notes.txt");
|
file.setFilename("notes.txt");
|
||||||
file.setPath("/docs");
|
file.setPath("/docs");
|
||||||
file.setStorageName("notes.txt");
|
|
||||||
file.setContentType("text/plain");
|
file.setContentType("text/plain");
|
||||||
file.setSize(5L);
|
file.setSize(5L);
|
||||||
file.setDirectory(false);
|
file.setDirectory(false);
|
||||||
|
file.setBlob(blob);
|
||||||
sharedFileId = storedFileRepository.save(file).getId();
|
sharedFileId = storedFileRepository.save(file).getId();
|
||||||
|
|
||||||
Path ownerDir = STORAGE_ROOT.resolve(owner.getId().toString()).resolve("docs");
|
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-notes");
|
||||||
Files.createDirectories(ownerDir);
|
Files.createDirectories(blobPath.getParent());
|
||||||
Files.writeString(ownerDir.resolve("notes.txt"), "hello", StandardCharsets.UTF_8);
|
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -152,6 +165,18 @@ class FileShareControllerIntegrationTest {
|
|||||||
.param("size", "20"))
|
.param("size", "20"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
.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
|
@Test
|
||||||
@@ -162,7 +187,6 @@ class FileShareControllerIntegrationTest {
|
|||||||
downloadDirectory.setUser(owner);
|
downloadDirectory.setUser(owner);
|
||||||
downloadDirectory.setFilename("下载");
|
downloadDirectory.setFilename("下载");
|
||||||
downloadDirectory.setPath("/");
|
downloadDirectory.setPath("/");
|
||||||
downloadDirectory.setStorageName("下载");
|
|
||||||
downloadDirectory.setContentType("directory");
|
downloadDirectory.setContentType("directory");
|
||||||
downloadDirectory.setSize(0L);
|
downloadDirectory.setSize(0L);
|
||||||
downloadDirectory.setDirectory(true);
|
downloadDirectory.setDirectory(true);
|
||||||
@@ -198,7 +222,6 @@ class FileShareControllerIntegrationTest {
|
|||||||
downloadDirectory.setUser(owner);
|
downloadDirectory.setUser(owner);
|
||||||
downloadDirectory.setFilename("下载");
|
downloadDirectory.setFilename("下载");
|
||||||
downloadDirectory.setPath("/");
|
downloadDirectory.setPath("/");
|
||||||
downloadDirectory.setStorageName("下载");
|
|
||||||
downloadDirectory.setContentType("directory");
|
downloadDirectory.setContentType("directory");
|
||||||
downloadDirectory.setSize(0L);
|
downloadDirectory.setSize(0L);
|
||||||
downloadDirectory.setDirectory(true);
|
downloadDirectory.setDirectory(true);
|
||||||
@@ -224,5 +247,13 @@ class FileShareControllerIntegrationTest {
|
|||||||
.param("size", "20"))
|
.param("size", "20"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
|
.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
|
@Test
|
||||||
@WithMockUser(username = "alice")
|
@WithMockUser(username = "alice")
|
||||||
void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception {
|
void shouldAllowAnonymousOfflineLookupJoinAndDownloadButKeepImportProtected() throws Exception {
|
||||||
String response = mockMvc.perform(post("/api/transfer/sessions")
|
String response = mockMvc.perform(post("/api/transfer/sessions")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
@@ -176,12 +176,27 @@ class TransferControllerIntegrationTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode))
|
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()))
|
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()))
|
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());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,8 @@
|
|||||||
|
|
||||||
- 兼容普通上传和 OSS 直传
|
- 兼容普通上传和 OSS 直传
|
||||||
- 前端会优先尝试“初始化上传 -> 直传/代理 -> 完成上传”
|
- 前端会优先尝试“初始化上传 -> 直传/代理 -> 完成上传”
|
||||||
|
- `upload/initiate` 返回的 `storageName` 现在是一次上传对应的 opaque blob object key;新文件会落到全局 `blobs/...` key,而不是用户目录路径 key
|
||||||
|
- `upload/complete` 必须回传这个 opaque blob key,后端会据此创建 `FileBlob` 并把新 `StoredFile` 绑定到该 blob
|
||||||
|
|
||||||
### 3.2 目录与列表
|
### 3.2 目录与列表
|
||||||
|
|
||||||
@@ -192,6 +194,7 @@
|
|||||||
- `move` 用于移动到目标路径
|
- `move` 用于移动到目标路径
|
||||||
- `copy` 用于复制到目标路径
|
- `copy` 用于复制到目标路径
|
||||||
- 文件和文件夹都支持移动 / 复制
|
- 文件和文件夹都支持移动 / 复制
|
||||||
|
- 普通文件的 `move` / `rename` / `copy` 只改逻辑元数据;`copy` 会复用原有 `FileBlob`,不会复制底层对象
|
||||||
|
|
||||||
### 3.5 分享链接
|
### 3.5 分享链接
|
||||||
|
|
||||||
@@ -204,6 +207,7 @@
|
|||||||
- 已登录用户可为自己的文件或文件夹创建分享链接
|
- 已登录用户可为自己的文件或文件夹创建分享链接
|
||||||
- 公开访客可查看分享详情
|
- 公开访客可查看分享详情
|
||||||
- 登录用户可将分享内容导入自己的网盘
|
- 登录用户可将分享内容导入自己的网盘
|
||||||
|
- 普通文件导入时会新建自己的 `StoredFile` 并复用源 `FileBlob`,不会再次写入物理文件
|
||||||
|
|
||||||
## 4. 快传模块
|
## 4. 快传模块
|
||||||
|
|
||||||
@@ -231,7 +235,7 @@
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 接收端通过 6 位取件码查找会话
|
- 接收端通过 6 位取件码查找会话
|
||||||
- 未登录用户只能查找在线快传
|
- 在线快传和离线快传都允许未登录用户查找
|
||||||
|
|
||||||
### 4.3 加入会话
|
### 4.3 加入会话
|
||||||
|
|
||||||
@@ -241,7 +245,7 @@
|
|||||||
|
|
||||||
- 在线快传会占用一次性会话
|
- 在线快传会占用一次性会话
|
||||||
- 离线快传返回可下载文件清单,不需要建立 P2P 通道
|
- 离线快传返回可下载文件清单,不需要建立 P2P 通道
|
||||||
- 未登录用户只能加入在线快传
|
- 在线快传和离线快传都允许未登录用户加入
|
||||||
|
|
||||||
### 4.4 信令交换
|
### 4.4 信令交换
|
||||||
|
|
||||||
@@ -281,7 +285,7 @@
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 需要登录
|
- 不需要登录
|
||||||
- 离线文件在有效期内可以被重复下载
|
- 离线文件在有效期内可以被重复下载
|
||||||
|
|
||||||
### 4.7 存入网盘
|
### 4.7 存入网盘
|
||||||
@@ -308,6 +312,16 @@
|
|||||||
- 用户总数
|
- 用户总数
|
||||||
- 文件总数
|
- 文件总数
|
||||||
- 当前邀请码
|
- 当前邀请码
|
||||||
|
- 今日请求次数
|
||||||
|
- 今日按小时请求折线图
|
||||||
|
- 最近 7 天每日上线人数和用户名单
|
||||||
|
- 当前离线快传占用与上限
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- `requestTimeline` 现在只返回当天已经过去的小时,例如当天只到 `07:xx` 时只会返回 `00:00` 到 `07:00`
|
||||||
|
- `dailyActiveUsers` 固定返回最近 7 天,按日期升序排列;每项包含日期、展示标签、当天去重后的上线人数和用户名列表
|
||||||
|
- “上线”定义为用户成功通过 JWT 鉴权访问受保护接口后的当天首次记录
|
||||||
|
|
||||||
### 5.2 用户管理
|
### 5.2 用户管理
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
2. Spring Boot 后端 API
|
2. Spring Boot 后端 API
|
||||||
3. 文件存储层(本地文件系统或 S3 兼容对象存储)
|
3. 文件存储层(本地文件系统或 S3 兼容对象存储)
|
||||||
|
|
||||||
|
当前前端除了作为 Web 站点发布外,也已支持通过 Capacitor 打包成 Android WebView 壳应用。
|
||||||
|
|
||||||
业务主线已经从旧教务方向切换为:
|
业务主线已经从旧教务方向切换为:
|
||||||
|
|
||||||
- 账号系统
|
- 账号系统
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
- 快传发/收流程
|
- 快传发/收流程
|
||||||
- 管理台前端
|
- 管理台前端
|
||||||
- 生产环境 API 基址拼装与调用
|
- 生产环境 API 基址拼装与调用
|
||||||
|
- Android WebView 壳的静态资源承载与 Capacitor 同步
|
||||||
|
|
||||||
关键入口:
|
关键入口:
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@
|
|||||||
- `front/src/main.tsx`
|
- `front/src/main.tsx`
|
||||||
- `front/src/lib/api.ts`
|
- `front/src/lib/api.ts`
|
||||||
- `front/src/components/layout/Layout.tsx`
|
- `front/src/components/layout/Layout.tsx`
|
||||||
|
- `front/capacitor.config.ts`
|
||||||
|
- `front/android/`
|
||||||
|
|
||||||
主要页面:
|
主要页面:
|
||||||
|
|
||||||
@@ -133,11 +138,26 @@
|
|||||||
关键实现说明:
|
关键实现说明:
|
||||||
|
|
||||||
- 文件元数据在数据库
|
- 文件元数据在数据库
|
||||||
- 文件内容走存储层抽象
|
- 文件内容通过独立 `FileBlob` 实体映射到底层对象;`StoredFile` 只负责用户、目录、文件名、路径、分享关系等逻辑元数据
|
||||||
|
- 新文件的物理对象 key 使用全局 `blobs/...` 命名,不再把 `userId/path` 编进对象 key
|
||||||
- 支持本地磁盘和 S3 兼容对象存储
|
- 支持本地磁盘和 S3 兼容对象存储
|
||||||
|
- 分享导入与网盘复制会直接复用源文件的 `FileBlob`,不会再次写入字节内容
|
||||||
|
- 文件重命名、移动只更新 `StoredFile` 元数据,不会移动底层对象
|
||||||
|
- 删除文件时会先删除 `StoredFile` 引用;只有最后一个引用消失时,才真正删除 `FileBlob` 对应的底层对象
|
||||||
|
- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取
|
||||||
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
|
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
|
||||||
- 前端会缓存目录列表和最后访问路径
|
- 前端会缓存目录列表和最后访问路径
|
||||||
|
|
||||||
|
Android 壳补充说明:
|
||||||
|
|
||||||
|
- Android 客户端当前使用 Capacitor 直接承载 `front/dist`,不单独维护原生业务页面
|
||||||
|
- 当前包名是 `xyz.yoyuzh.portal`
|
||||||
|
- 前端 API 基址在 Web 与 Android 壳上分开解析:网页继续走相对 `/api`,Capacitor `localhost` 壳在 `http://localhost` 与 `https://localhost` 下都默认改走 `https://api.yoyuzh.xyz/api`
|
||||||
|
- 后端 CORS 默认放行 `http://localhost`、`https://localhost`、`http://127.0.0.1`、`https://127.0.0.1` 与 `capacitor://localhost`,以兼容 Web 开发环境和 Android WebView 壳
|
||||||
|
- Web 端构建完成后,通过 `npx cap sync android` 把静态资源复制到 `front/android/app/src/main/assets/public`
|
||||||
|
- Android 调试包当前通过 `cd front/android && ./gradlew assembleDebug` 生成,输出路径是 `front/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
- 由于当前开发机直连 `dl.google.com` 与 Google Android Maven 仓库存在 TLS 握手失败,本地 Android 构建仓库源已切到可访问镜像;如果后续重新生成 Capacitor 工程,需要重新确认镜像配置仍存在
|
||||||
|
|
||||||
### 3.3 快传模块
|
### 3.3 快传模块
|
||||||
|
|
||||||
核心文件:
|
核心文件:
|
||||||
@@ -166,8 +186,10 @@
|
|||||||
- 多文件或文件夹可走 ZIP 下载
|
- 多文件或文件夹可走 ZIP 下载
|
||||||
- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话
|
- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话
|
||||||
- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收
|
- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收
|
||||||
- 登录页提供直达快传入口;匿名用户只允许创建在线快传并接收在线快传,离线快传相关操作仍要求登录
|
- 登录页提供直达快传入口;匿名用户允许创建在线快传、接收在线快传和接收离线快传,离线快传的发送以及“存入网盘”仍要求登录
|
||||||
- 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层
|
- 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层
|
||||||
|
- 生产环境当前已经部署 `GET /api/transfer/sessions/offline/mine`,用于驱动“我的离线快传”列表
|
||||||
|
- 前端默认内置 STUN 服务器,并支持通过 `VITE_TRANSFER_ICE_SERVERS_JSON` 追加 TURN / ICE 配置;未配置 TURN 时,跨运营商或手机蜂窝网络下的在线 P2P 直连不保证成功
|
||||||
|
|
||||||
### 3.4 管理台模块
|
### 3.4 管理台模块
|
||||||
|
|
||||||
@@ -182,7 +204,7 @@
|
|||||||
- 管理用户
|
- 管理用户
|
||||||
- 管理文件
|
- 管理文件
|
||||||
- 查看邀请码
|
- 查看邀请码
|
||||||
- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用和请求折线图
|
- 展示总存储量、下载流量、今日请求次数、快传使用量、离线快传占用、请求折线图和最近 7 天上线记录
|
||||||
- 调整离线快传总上限
|
- 调整离线快传总上限
|
||||||
|
|
||||||
关键实现说明:
|
关键实现说明:
|
||||||
@@ -191,6 +213,8 @@
|
|||||||
- 当前邀请码由后端返回给管理台展示
|
- 当前邀请码由后端返回给管理台展示
|
||||||
- 用户列表会展示每个用户的已用空间 / 配额
|
- 用户列表会展示每个用户的已用空间 / 配额
|
||||||
- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录
|
- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录
|
||||||
|
- JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天
|
||||||
|
- 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点
|
||||||
|
|
||||||
## 4. 关键业务流程
|
## 4. 关键业务流程
|
||||||
|
|
||||||
@@ -198,6 +222,7 @@
|
|||||||
|
|
||||||
- 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳
|
- 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳
|
||||||
- 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App`
|
- 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App`
|
||||||
|
- 移动端 `MobileFiles` 与 `MobileTransfer` 独立维护页面级动态光晕层,视觉上与桌面端网盘/快传保持同一背景语言
|
||||||
|
|
||||||
### 4.1 登录流程
|
### 4.1 登录流程
|
||||||
|
|
||||||
@@ -226,16 +251,19 @@
|
|||||||
|
|
||||||
1. 前端在 `Files` 页面选择文件或文件夹
|
1. 前端在 `Files` 页面选择文件或文件夹
|
||||||
2. 前端优先调用 `/api/files/upload/initiate`
|
2. 前端优先调用 `/api/files/upload/initiate`
|
||||||
3. 如果存储支持直传,则浏览器直接上传到对象存储
|
3. 后端为新文件预留一个全局 blob object key(`blobs/...`)并返回给前端
|
||||||
4. 前端再调用 `/api/files/upload/complete`
|
4. 如果存储支持直传,则浏览器直接把字节上传到该 blob key
|
||||||
5. 如果直传失败,会回退到代理上传接口 `/api/files/upload`
|
5. 前端再调用 `/api/files/upload/complete`
|
||||||
|
6. 如果直传失败,会回退到代理上传接口 `/api/files/upload`
|
||||||
|
7. 后端创建 `FileBlob`,再创建指向该 blob 的 `StoredFile`
|
||||||
|
|
||||||
### 4.4 文件分享流程
|
### 4.4 文件分享流程
|
||||||
|
|
||||||
1. 登录用户创建分享链接
|
1. 登录用户创建分享链接
|
||||||
2. 后端生成 token
|
2. 后端生成 token
|
||||||
3. 公开用户通过 `/share/:token` 查看详情
|
3. 公开用户通过 `/share/:token` 查看详情
|
||||||
4. 登录用户可以导入到自己的网盘
|
4. 登录用户导入时会新建自己的 `StoredFile`
|
||||||
|
5. 若源对象是普通文件,则新条目直接复用源 `FileBlob`,不会复制物理内容
|
||||||
|
|
||||||
### 4.5 快传流程
|
### 4.5 快传流程
|
||||||
|
|
||||||
@@ -258,7 +286,7 @@
|
|||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
- 离线快传的创建、查找、加入和下载都要求登录
|
- 离线快传只有“创建会话 / 上传文件 / 存入网盘”要求登录;匿名用户可以查找、加入和下载离线快传
|
||||||
- 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式
|
- 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式
|
||||||
- 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息
|
- 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息
|
||||||
|
|
||||||
|
|||||||
115
docs/superpowers/plans/2026-04-02-shared-file-blob-storage.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Shared File Blob Storage Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将网盘文件模型改造成 `StoredFile -> FileBlob` 引用关系,让分享导入与网盘复制复用同一份底层对象,而不是再次写入物理文件。
|
||||||
|
|
||||||
|
**Architecture:** 新增独立 `FileBlob` 实体承载真实对象 key、大小和内容类型,`StoredFile` 只保留逻辑目录元数据并引用 `FileBlob`。网盘上传为每个新文件创建新 blob,分享导入和文件复制直接复用 blob;删除时按引用是否归零决定是否删除底层对象。补一个面向旧 `portal_file.storage_name` 数据的一次性回填路径,避免线上旧数据在新模型下失联。
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.3.8, Spring Data JPA, Java 17, Maven, H2 tests, local filesystem storage, S3-compatible object storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Define Blob Data Model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/src/main/java/com/yoyuzh/files/FileBlob.java`
|
||||||
|
- Create: `backend/src/main/java/com/yoyuzh/files/FileBlobRepository.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add/update backend tests that expect file copies and share imports to preserve a shared blob id rather than a per-user storage name.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileShareControllerIntegrationTest`
|
||||||
|
Expected: FAIL because `StoredFile` does not yet expose blob references and current logic still duplicates file content.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add `FileBlob` entity/repository, move file-object ownership from `StoredFile.storageName` to `StoredFile.blob`, and add repository helpers for blob reference counting / physical size queries.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileShareControllerIntegrationTest`
|
||||||
|
Expected: PASS for data-model expectations.
|
||||||
|
|
||||||
|
### Task 2: Refactor File Storage Flow To Blob Keys
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/FileContentStorage.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/LocalFileContentStorage.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/storage/S3FileContentStorage.java`
|
||||||
|
- Test: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java`
|
||||||
|
- Test: `backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add/update tests for:
|
||||||
|
- upload creates a new blob
|
||||||
|
- share import reuses the source blob and does not store bytes again
|
||||||
|
- file copy reuses the source blob and does not copy bytes again
|
||||||
|
- deleting a non-final reference keeps the blob/object alive
|
||||||
|
- deleting the final reference removes the blob/object
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileServiceEdgeCaseTest`
|
||||||
|
Expected: FAIL because current service still calls `readFile/storeImportedFile/copyFile/moveFile/renameFile` for ordinary files.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Refactor file upload/download/import/copy/delete to operate on blob object keys. Keep directory behavior metadata-only. Ensure file rename/move/copy no longer trigger physical object mutations, and deletion only removes the object when the last `StoredFile` reference disappears.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileServiceTest,FileServiceEdgeCaseTest`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Backfill Old File Rows Into Blob References
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/src/main/java/com/yoyuzh/files/FileBlobBackfillService.java`
|
||||||
|
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
|
||||||
|
- Test: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add an integration-path expectation that persisted file rows still download/import correctly after the blob model change.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileShareControllerIntegrationTest`
|
||||||
|
Expected: FAIL because legacy `portal_file` rows created without blobs can no longer resolve file content.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add a startup/on-demand backfill that creates `FileBlob` rows for existing non-directory files using their legacy object key and attaches them to `StoredFile` rows with missing blob references.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test -Dtest=FileShareControllerIntegrationTest`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Update Docs And Full Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `memory.md`
|
||||||
|
- Modify: `docs/architecture.md`
|
||||||
|
- Modify: `docs/api-reference.md` (only if API semantics changed)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Document the new storage model**
|
||||||
|
|
||||||
|
Record that logical file metadata now points to shared blobs, that share import and copy reuse blobs, and that physical keys are global rather than user-path keys.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full backend verification**
|
||||||
|
|
||||||
|
Run: `cd /Users/mac/Documents/my_site/backend && mvn test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Summarize migration requirement**
|
||||||
|
|
||||||
|
In the final handoff, call out that old production data must be backfilled to `FileBlob` rows before or during rollout; otherwise pre-existing files cannot resolve blob references under the new model.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Transfer Simple Peer Integration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the hand-rolled online transfer WebRTC peer wiring with `simple-peer` while preserving the current pickup-code flow, backend signaling APIs, and offline transfer mode.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing product boundaries: Spring Boot remains a dumb signaling relay and session store, while the React frontend owns online-transfer peer creation and file streaming. Instead of manually managing `RTCPeerConnection`, ICE candidates, and SDP state across sender/receiver pages, introduce a thin `simple-peer` adapter that serializes peer signals through the existing `/api/transfer/sessions/{id}/signals` endpoints and reuses the current transfer control/data protocol.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite 6, React 19, TypeScript, node:test, `simple-peer`, existing Spring Boot transfer signaling API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock the new peer adapter contract with failing tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `front/src/lib/transfer-peer.test.ts`
|
||||||
|
- Create: `front/src/lib/transfer-peer.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add tests that assert:
|
||||||
|
- local `simple-peer` signal events serialize into a backend-friendly payload
|
||||||
|
- incoming backend signal payloads are routed back into the peer instance
|
||||||
|
- peer connection lifecycle maps to app-friendly callbacks without exposing raw browser SDP/ICE handling to pages
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test -- src/lib/transfer-peer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create a focused adapter around `simple-peer` with:
|
||||||
|
- sender/receiver construction
|
||||||
|
- `signal` event forwarding
|
||||||
|
- `connect`, `data`, `close`, and `error` callbacks
|
||||||
|
- send helpers used by the existing transfer pages
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test -- src/lib/transfer-peer.test.ts`
|
||||||
|
|
||||||
|
### Task 2: Replace online sender wiring in the desktop and mobile transfer pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/pages/Transfer.tsx`
|
||||||
|
- Modify: `front/src/mobile-pages/MobileTransfer.tsx`
|
||||||
|
- Modify: `front/src/lib/transfer.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add or extend focused tests for the new signaling payload shape if needed so the sender path no longer depends on manual offer/answer/ICE branches.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Use the adapter in both sender pages:
|
||||||
|
- build an initiator peer instead of a raw `RTCPeerConnection`
|
||||||
|
- post serialized peer signals through the existing backend endpoint
|
||||||
|
- keep the current file manifest and binary chunk sending protocol
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test`
|
||||||
|
|
||||||
|
### Task 3: Replace online receiver wiring while keeping the current receive UX
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `front/src/pages/TransferReceive.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add or update tests around receiver signal handling and data delivery if gaps remain after Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Use the adapter in the receiver page:
|
||||||
|
- build a non-initiator peer
|
||||||
|
- feed backend-delivered signals into it
|
||||||
|
- keep the current control messages, archive flow, and netdisk save flow unchanged
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test`
|
||||||
|
|
||||||
|
### Task 4: Verification and release
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify if required by validation failures: `front/package.json`, `front/package-lock.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the dependency**
|
||||||
|
|
||||||
|
Run: `cd front && npm install simple-peer @types/simple-peer`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run frontend tests**
|
||||||
|
|
||||||
|
Run: `cd front && npm run test`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run frontend typecheck**
|
||||||
|
|
||||||
|
Run: `cd front && npm run lint`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run frontend build**
|
||||||
|
|
||||||
|
Run: `cd front && npm run build`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Publish the frontend**
|
||||||
|
|
||||||
|
Run: `node scripts/deploy-front-oss.mjs`
|
||||||
101
front/android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
2
front/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
54
front/android/app/build.gradle
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "xyz.yoyuzh.portal"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "xyz.yoyuzh.portal"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
19
front/android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
21
front/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
41
front/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package xyz.yoyuzh.portal;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
BIN
front/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
front/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
front/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
front/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
front/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
front/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
front/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
front/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
front/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
front/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
BIN
front/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
front/android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
BIN
front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
7
front/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YOYUZH</string>
|
||||||
|
<string name="title_activity_main">YOYUZH</string>
|
||||||
|
<string name="package_name">xyz.yoyuzh.portal</string>
|
||||||
|
<string name="custom_url_scheme">xyz.yoyuzh.portal</string>
|
||||||
|
</resources>
|
||||||
22
front/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
front/android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
front/android/build.gradle
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
def googleMirror = 'https://maven.aliyun.com/repository/google'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url googleMirror }
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
def googleMirror = 'https://maven.aliyun.com/repository/google'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url googleMirror }
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
3
front/android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
22
front/android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
BIN
front/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
front/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
front/android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
front/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
5
front/android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
16
front/android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
9
front/capacitor.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'xyz.yoyuzh.portal',
|
||||||
|
appName: 'YOYUZH',
|
||||||
|
webDir: 'dist'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>优立云盘</title>
|
<title>优立云盘</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
954
front/package-lock.json
generated
@@ -12,12 +12,16 @@
|
|||||||
"test": "node --import tsx --test src/**/*.test.ts"
|
"test": "node --import tsx --test src/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.3.0",
|
||||||
|
"@capacitor/cli": "^8.3.0",
|
||||||
|
"@capacitor/core": "^8.3.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@mui/icons-material": "^7.3.9",
|
"@mui/icons-material": "^7.3.9",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
"react-admin": "^5.14.4",
|
"react-admin": "^5.14.4",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
"simple-peer": "^9.11.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildRequestLineChartModel,
|
buildRequestLineChartModel,
|
||||||
|
buildRequestLineChartXAxisPoints,
|
||||||
formatMetricValue,
|
formatMetricValue,
|
||||||
getInviteCodePanelState,
|
getInviteCodePanelState,
|
||||||
parseStorageLimitInput,
|
parseStorageLimitInput,
|
||||||
@@ -19,6 +20,7 @@ test('getInviteCodePanelState returns a copyable invite code when summary contai
|
|||||||
transferUsageBytes: 0,
|
transferUsageBytes: 0,
|
||||||
offlineTransferStorageBytes: 0,
|
offlineTransferStorageBytes: 0,
|
||||||
offlineTransferStorageLimitBytes: 0,
|
offlineTransferStorageLimitBytes: 0,
|
||||||
|
dailyActiveUsers: [],
|
||||||
requestTimeline: [],
|
requestTimeline: [],
|
||||||
inviteCode: ' AbCd1234 ',
|
inviteCode: ' AbCd1234 ',
|
||||||
}),
|
}),
|
||||||
@@ -40,6 +42,7 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
|
|||||||
transferUsageBytes: 0,
|
transferUsageBytes: 0,
|
||||||
offlineTransferStorageBytes: 0,
|
offlineTransferStorageBytes: 0,
|
||||||
offlineTransferStorageLimitBytes: 0,
|
offlineTransferStorageLimitBytes: 0,
|
||||||
|
dailyActiveUsers: [],
|
||||||
requestTimeline: [],
|
requestTimeline: [],
|
||||||
inviteCode: ' ',
|
inviteCode: ' ',
|
||||||
}),
|
}),
|
||||||
@@ -87,3 +90,38 @@ test('buildRequestLineChartModel converts hourly request data into chart coordin
|
|||||||
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
|
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
|
||||||
assert.equal(model.peakPoint?.label, '02:00');
|
assert.equal(model.peakPoint?.label, '02:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
|
||||||
|
const model = buildRequestLineChartModel([
|
||||||
|
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||||
|
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||||
|
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||||
|
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||||
|
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||||
|
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||||
|
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||||
|
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(model.points[0]?.x, 0);
|
||||||
|
assert.equal(model.points.at(-1)?.x, 100);
|
||||||
|
assert.equal(model.points.length, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
|
||||||
|
const model = buildRequestLineChartModel([
|
||||||
|
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||||
|
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||||
|
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||||
|
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||||
|
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||||
|
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||||
|
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||||
|
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
|
||||||
|
['00:00', '06:00', '07:00'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface RequestLineChartModel {
|
|||||||
type MetricValueKind = 'bytes' | 'count';
|
type MetricValueKind = 'bytes' | 'count';
|
||||||
|
|
||||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
|
||||||
|
|
||||||
export function formatMetricValue(value: number, kind: MetricValueKind): string {
|
export function formatMetricValue(value: number, kind: MetricValueKind): string {
|
||||||
if (kind === 'count') {
|
if (kind === 'count') {
|
||||||
@@ -103,6 +104,23 @@ export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstHour = points[0]?.hour ?? 0;
|
||||||
|
const lastHour = points.at(-1)?.hour ?? firstHour;
|
||||||
|
const visibleHours = new Set<number>([firstHour, lastHour]);
|
||||||
|
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
|
||||||
|
if (hour > firstHour && hour < lastHour) {
|
||||||
|
visibleHours.add(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.filter((point) => visibleHours.has(point.hour));
|
||||||
|
}
|
||||||
|
|
||||||
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
||||||
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
||||||
if (!inviteCode) {
|
if (!inviteCode) {
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { apiRequest } from '@/src/lib/api';
|
import { apiRequest } from '@/src/lib/api';
|
||||||
import { readStoredSession } from '@/src/lib/session';
|
import { readStoredSession } from '@/src/lib/session';
|
||||||
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
|
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
|
||||||
import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
|
import {
|
||||||
|
buildRequestLineChartModel,
|
||||||
|
buildRequestLineChartXAxisPoints,
|
||||||
|
formatMetricValue,
|
||||||
|
getInviteCodePanelState,
|
||||||
|
parseStorageLimitInput,
|
||||||
|
} from './dashboard-state';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: AdminSummary | null;
|
summary: AdminSummary | null;
|
||||||
@@ -30,7 +36,6 @@ interface MetricCardDefinition {
|
|||||||
helper: string;
|
helper: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]);
|
|
||||||
const DASHBOARD_CARD_BG = '#111827';
|
const DASHBOARD_CARD_BG = '#111827';
|
||||||
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
|
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
|
||||||
const DASHBOARD_CARD_TEXT = '#f8fafc';
|
const DASHBOARD_CARD_TEXT = '#f8fafc';
|
||||||
@@ -129,7 +134,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
|||||||
const chart = buildRequestLineChartModel(summary.requestTimeline);
|
const chart = buildRequestLineChartModel(summary.requestTimeline);
|
||||||
const currentHour = new Date().getHours();
|
const currentHour = new Date().getHours();
|
||||||
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
|
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
|
||||||
const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour));
|
const xAxisPoints = buildRequestLineChartXAxisPoints(chart.points);
|
||||||
const hasRequests = chart.maxValue > 0;
|
const hasRequests = chart.maxValue > 0;
|
||||||
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
|
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
|||||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。
|
按小时统计今天已发生的 `/api/**` 请求;曲线会随当天已过时间自然拉长,不再预留未来小时。
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -340,21 +345,26 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
|||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chart.points.map((point) => (
|
|
||||||
<circle
|
|
||||||
key={point.label}
|
|
||||||
cx={point.x}
|
|
||||||
cy={point.y}
|
|
||||||
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
|
|
||||||
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="1.2"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{chart.points.map((point) => (
|
||||||
|
<Box
|
||||||
|
key={point.label}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${point.x}%`,
|
||||||
|
top: `${point.y}%`,
|
||||||
|
width: point.hour === currentPoint?.hour ? 8 : 6,
|
||||||
|
height: point.hour === currentPoint?.hour ? 8 : 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{!hasRequests && (
|
{!hasRequests && (
|
||||||
<Stack
|
<Stack
|
||||||
spacing={0.4}
|
spacing={0.4}
|
||||||
@@ -405,6 +415,141 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DailyActiveUsersCard({ summary }: { summary: AdminSummary }) {
|
||||||
|
const latestDay = summary.dailyActiveUsers.at(-1) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
sx={(theme) => ({
|
||||||
|
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
|
||||||
|
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
|
||||||
|
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.75}>
|
||||||
|
<Typography variant="h6" fontWeight={700}>
|
||||||
|
最近 7 天上线记录
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
JWT 鉴权成功后会记录当天首次上线用户,只保留最近 7 天,便于回看每天有多少人上线以及具体是谁。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
spacing={0.35}
|
||||||
|
sx={{
|
||||||
|
minWidth: 156,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(16, 185, 129, 0.12)' : '#ecfdf5',
|
||||||
|
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(52, 211, 153, 0.18)' : '1px solid transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
|
})}
|
||||||
|
fontWeight={700}
|
||||||
|
>
|
||||||
|
今日上线人数
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatMetricValue(latestDay?.userCount ?? 0, 'count')}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{latestDay?.label ?? '--'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.2}>
|
||||||
|
{summary.dailyActiveUsers.slice().reverse().map((day) => (
|
||||||
|
<Box
|
||||||
|
key={day.metricDate}
|
||||||
|
sx={(theme) => ({
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)',
|
||||||
|
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.03)' : '#f8fafc',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
|
spacing={1.25}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Typography fontWeight={700}>{day.label}</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={(theme) => ({
|
||||||
|
px: 0.9,
|
||||||
|
py: 0.3,
|
||||||
|
borderRadius: 99,
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||||
|
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(59, 130, 246, 0.18)' : '#dbeafe',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatMetricValue(day.userCount, 'count')} 人
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{day.metricDate}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{day.usernames.length > 0 ? day.usernames.join('、') : '当天无人上线'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PortalAdminDashboard() {
|
export function PortalAdminDashboard() {
|
||||||
const [state, setState] = useState<DashboardState>({
|
const [state, setState] = useState<DashboardState>({
|
||||||
summary: null,
|
summary: null,
|
||||||
@@ -586,7 +731,12 @@ export function PortalAdminDashboard() {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{summary && <RequestTrendChart summary={summary} />}
|
{summary && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<RequestTrendChart summary={summary} />
|
||||||
|
<DailyActiveUsersCard summary={summary} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
|||||||
transferUsageBytes: 0,
|
transferUsageBytes: 0,
|
||||||
offlineTransferStorageBytes: 0,
|
offlineTransferStorageBytes: 0,
|
||||||
offlineTransferStorageLimitBytes: 0,
|
offlineTransferStorageLimitBytes: 0,
|
||||||
|
dailyActiveUsers: [],
|
||||||
requestTimeline: [],
|
requestTimeline: [],
|
||||||
inviteCode: 'invite-code',
|
inviteCode: 'invite-code',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,12 +19,29 @@
|
|||||||
--color-glass-active: rgba(255, 255, 255, 0.1);
|
--color-glass-active: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
|
||||||
|
--app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
/* Custom Scrollbar */
|
||||||
@@ -59,6 +76,14 @@ body {
|
|||||||
background: var(--color-glass-active);
|
background: var(--color-glass-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.safe-area-pt {
|
||||||
|
padding-top: var(--app-safe-area-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area-pb {
|
||||||
|
padding-bottom: var(--app-safe-area-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes blob {
|
@keyframes blob {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class MemoryStorage implements Storage {
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalStorage = globalThis.localStorage;
|
const originalStorage = globalThis.localStorage;
|
||||||
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||||
|
const originalLocation = globalThis.location;
|
||||||
|
|
||||||
class FakeXMLHttpRequest {
|
class FakeXMLHttpRequest {
|
||||||
static latest: FakeXMLHttpRequest | null = null;
|
static latest: FakeXMLHttpRequest | null = null;
|
||||||
@@ -136,6 +137,10 @@ afterEach(() => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: originalXMLHttpRequest,
|
value: originalXMLHttpRequest,
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(globalThis, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
||||||
@@ -180,6 +185,74 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
|
|||||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
|
||||||
|
let request: Request | URL | string | undefined;
|
||||||
|
Object.defineProperty(globalThis, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL('http://localhost'),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
request =
|
||||||
|
input instanceof Request
|
||||||
|
? input
|
||||||
|
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'success',
|
||||||
|
data: {
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiRequest<{ok: boolean}>('/files/recent');
|
||||||
|
|
||||||
|
assert.ok(request instanceof Request);
|
||||||
|
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => {
|
||||||
|
let request: Request | URL | string | undefined;
|
||||||
|
Object.defineProperty(globalThis, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: new URL('https://localhost'),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
request =
|
||||||
|
input instanceof Request
|
||||||
|
? input
|
||||||
|
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'success',
|
||||||
|
data: {
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiRequest<{ok: boolean}>('/files/recent');
|
||||||
|
|
||||||
|
assert.ok(request instanceof Request);
|
||||||
|
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||||
|
});
|
||||||
|
|
||||||
test('apiRequest throws backend message on business error', async () => {
|
test('apiRequest throws backend message on business error', async () => {
|
||||||
globalThis.fetch = async () =>
|
globalThis.fetch = async () =>
|
||||||
new Response(
|
new Response(
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ interface ApiBinaryUploadRequestInit {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
|
||||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
const AUTH_REFRESH_PATH = '/auth/refresh';
|
||||||
|
const DEFAULT_API_BASE_URL = '/api';
|
||||||
|
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
|
||||||
|
|
||||||
let refreshRequestPromise: Promise<boolean> | null = null;
|
let refreshRequestPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
@@ -90,13 +91,57 @@ function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attemp
|
|||||||
return getRetryDelayMs(attempt);
|
return getRetryDelayMs(attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeLocation() {
|
||||||
|
if (typeof globalThis.location !== 'undefined') {
|
||||||
|
return globalThis.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCapacitorLocalhostOrigin(location: Location | URL | null) {
|
||||||
|
if (!location) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = location.protocol || '';
|
||||||
|
const hostname = location.hostname || '';
|
||||||
|
const port = location.port || '';
|
||||||
|
|
||||||
|
if (protocol === 'capacitor:') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
|
||||||
|
|
||||||
|
return isCapacitorLocalScheme && isLocalhostHost && port === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiBaseUrl() {
|
||||||
|
const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, '');
|
||||||
|
if (configuredBaseUrl) {
|
||||||
|
return configuredBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) {
|
||||||
|
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUrl(path: string) {
|
function resolveUrl(path: string) {
|
||||||
if (/^https?:\/\//.test(path)) {
|
if (/^https?:\/\//.test(path)) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
return `${API_BASE_URL}${normalizedPath}`;
|
return `${getApiBaseUrl()}${normalizedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePath(path: string) {
|
function normalizePath(path: string) {
|
||||||
|
|||||||
44
front/src/lib/transfer-ice.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
|
hasRelayTransferIceServer,
|
||||||
|
resolveTransferIceServers,
|
||||||
|
} from './transfer-ice';
|
||||||
|
|
||||||
|
test('resolveTransferIceServers falls back to the default STUN list when no custom config is provided', () => {
|
||||||
|
assert.deepEqual(resolveTransferIceServers(), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||||
|
assert.deepEqual(resolveTransferIceServers(''), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||||
|
assert.deepEqual(resolveTransferIceServers('not-json'), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTransferIceServers appends custom TURN servers after the default STUN list', () => {
|
||||||
|
const iceServers = resolveTransferIceServers(JSON.stringify([
|
||||||
|
{
|
||||||
|
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||||
|
username: 'portal-user',
|
||||||
|
credential: 'portal-secret',
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert.deepEqual(iceServers, [
|
||||||
|
...DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
|
{
|
||||||
|
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||||
|
username: 'portal-user',
|
||||||
|
credential: 'portal-secret',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasRelayTransferIceServer detects whether TURN relay servers are configured', () => {
|
||||||
|
assert.equal(hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS), false);
|
||||||
|
assert.equal(hasRelayTransferIceServer(resolveTransferIceServers(JSON.stringify([
|
||||||
|
{
|
||||||
|
urls: 'turn:turn.yoyuzh.xyz:3478?transport=udp',
|
||||||
|
username: 'portal-user',
|
||||||
|
credential: 'portal-secret',
|
||||||
|
},
|
||||||
|
]))), true);
|
||||||
|
});
|
||||||
91
front/src/lib/transfer-ice.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
const DEFAULT_STUN_ICE_SERVERS: RTCIceServer[] = [
|
||||||
|
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RELAY_HINT =
|
||||||
|
'当前环境只配置了 STUN,跨运营商或手机移动网络通常还需要 TURN 中继。';
|
||||||
|
|
||||||
|
type RawIceServer = {
|
||||||
|
urls?: unknown;
|
||||||
|
username?: unknown;
|
||||||
|
credential?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TRANSFER_ICE_SERVERS = DEFAULT_STUN_ICE_SERVERS;
|
||||||
|
|
||||||
|
export function resolveTransferIceServers(rawConfig = import.meta.env?.VITE_TRANSFER_ICE_SERVERS_JSON) {
|
||||||
|
if (typeof rawConfig !== 'string' || !rawConfig.trim()) {
|
||||||
|
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawConfig) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customServers = parsed
|
||||||
|
.map(normalizeIceServer)
|
||||||
|
.filter((server): server is RTCIceServer => server != null);
|
||||||
|
|
||||||
|
if (customServers.length === 0) {
|
||||||
|
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...DEFAULT_TRANSFER_ICE_SERVERS, ...customServers];
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRelayTransferIceServer(iceServers: RTCIceServer[]) {
|
||||||
|
return iceServers.some((server) => toUrls(server.urls).some((url) => /^turns?:/i.test(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendTransferRelayHint(message: string, hasRelaySupport: boolean) {
|
||||||
|
const normalizedMessage = message.trim();
|
||||||
|
if (!normalizedMessage || hasRelaySupport || normalizedMessage.includes(RELAY_HINT)) {
|
||||||
|
return normalizedMessage;
|
||||||
|
}
|
||||||
|
return `${normalizedMessage} ${RELAY_HINT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIceServer(rawServer: RawIceServer) {
|
||||||
|
const urls = normalizeUrls(rawServer?.urls);
|
||||||
|
if (urls == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server: RTCIceServer = { urls };
|
||||||
|
if (typeof rawServer.username === 'string' && rawServer.username.trim()) {
|
||||||
|
server.username = rawServer.username.trim();
|
||||||
|
}
|
||||||
|
if (typeof rawServer.credential === 'string' && rawServer.credential.trim()) {
|
||||||
|
server.credential = rawServer.credential.trim();
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrls(rawUrls: unknown): string | string[] | null {
|
||||||
|
if (typeof rawUrls === 'string' && rawUrls.trim()) {
|
||||||
|
return rawUrls.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(rawUrls)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = rawUrls
|
||||||
|
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||||
|
.map((item) => item.trim());
|
||||||
|
|
||||||
|
return urls.length > 0 ? urls : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrls(urls: string | string[] | undefined) {
|
||||||
|
if (!urls) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(urls) ? urls : [urls];
|
||||||
|
}
|
||||||