Migrate storage to DogeCloud and expand admin dashboard
This commit is contained in:
@@ -29,6 +29,14 @@ public class AdminController {
|
||||
return ApiResponse.success(adminService.getSummary());
|
||||
}
|
||||
|
||||
@PatchMapping("/settings/offline-transfer-storage-limit")
|
||||
public ApiResponse<AdminOfflineTransferStorageLimitResponse> updateOfflineTransferStorageLimit(
|
||||
@Valid @RequestBody AdminOfflineTransferStorageLimitUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateOfflineTransferStorageLimit(
|
||||
request.offlineTransferStorageLimitBytes()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/users")
|
||||
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
|
||||
161
backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java
Normal file
161
backend/src/main/java/com/yoyuzh/admin/AdminMetricsService.java
Normal file
@@ -0,0 +1,161 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminMetricsService {
|
||||
|
||||
private static final Long STATE_ID = 1L;
|
||||
private static final long DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES = 20L * 1024 * 1024 * 1024;
|
||||
|
||||
private final AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
private final AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||
|
||||
@Transactional
|
||||
public AdminMetricsSnapshot getSnapshot() {
|
||||
LocalDate today = LocalDate.now();
|
||||
AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentState(), today, true);
|
||||
return toSnapshot(state, today);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long getOfflineTransferStorageLimitBytes() {
|
||||
return ensureCurrentState().getOfflineTransferStorageLimitBytes();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void incrementRequestCount() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDate today = now.toLocalDate();
|
||||
AdminMetricsState state = refreshRequestCountDateIfNeeded(ensureCurrentStateForUpdate(), today, false);
|
||||
state.setRequestCount(state.getRequestCount() + 1);
|
||||
adminMetricsStateRepository.save(state);
|
||||
incrementRequestTimelinePoint(today, now.getHour());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordDownloadTraffic(long bytes) {
|
||||
if (bytes <= 0) {
|
||||
return;
|
||||
}
|
||||
AdminMetricsState state = ensureCurrentStateForUpdate();
|
||||
state.setDownloadTrafficBytes(state.getDownloadTrafficBytes() + bytes);
|
||||
adminMetricsStateRepository.save(state);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordTransferUsage(long bytes) {
|
||||
if (bytes <= 0) {
|
||||
return;
|
||||
}
|
||||
AdminMetricsState state = ensureCurrentStateForUpdate();
|
||||
state.setTransferUsageBytes(state.getTransferUsageBytes() + bytes);
|
||||
adminMetricsStateRepository.save(state);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
|
||||
AdminMetricsState state = ensureCurrentStateForUpdate();
|
||||
state.setOfflineTransferStorageLimitBytes(offlineTransferStorageLimitBytes);
|
||||
AdminMetricsState saved = adminMetricsStateRepository.save(state);
|
||||
return new AdminOfflineTransferStorageLimitResponse(saved.getOfflineTransferStorageLimitBytes());
|
||||
}
|
||||
|
||||
private AdminMetricsSnapshot toSnapshot(AdminMetricsState state, LocalDate metricDate) {
|
||||
return new AdminMetricsSnapshot(
|
||||
state.getRequestCount(),
|
||||
state.getDownloadTrafficBytes(),
|
||||
state.getTransferUsageBytes(),
|
||||
state.getOfflineTransferStorageLimitBytes(),
|
||||
buildRequestTimeline(metricDate)
|
||||
);
|
||||
}
|
||||
|
||||
private AdminMetricsState ensureCurrentState() {
|
||||
return adminMetricsStateRepository.findById(STATE_ID)
|
||||
.orElseGet(this::createInitialState);
|
||||
}
|
||||
|
||||
private AdminMetricsState ensureCurrentStateForUpdate() {
|
||||
return adminMetricsStateRepository.findByIdForUpdate(STATE_ID)
|
||||
.orElseGet(() -> {
|
||||
createInitialState();
|
||||
return adminMetricsStateRepository.findByIdForUpdate(STATE_ID)
|
||||
.orElseThrow(() -> new IllegalStateException("管理统计状态初始化失败"));
|
||||
});
|
||||
}
|
||||
|
||||
private AdminMetricsState createInitialState() {
|
||||
AdminMetricsState state = new AdminMetricsState();
|
||||
state.setId(STATE_ID);
|
||||
state.setRequestCount(0L);
|
||||
state.setRequestCountDate(LocalDate.now());
|
||||
state.setDownloadTrafficBytes(0L);
|
||||
state.setTransferUsageBytes(0L);
|
||||
state.setOfflineTransferStorageLimitBytes(DEFAULT_OFFLINE_TRANSFER_STORAGE_LIMIT_BYTES);
|
||||
try {
|
||||
return adminMetricsStateRepository.saveAndFlush(state);
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
return adminMetricsStateRepository.findById(STATE_ID)
|
||||
.orElseThrow(() -> ignored);
|
||||
}
|
||||
}
|
||||
|
||||
private AdminMetricsState refreshRequestCountDateIfNeeded(AdminMetricsState state, LocalDate today, boolean persistImmediately) {
|
||||
if (today.equals(state.getRequestCountDate())) {
|
||||
return state;
|
||||
}
|
||||
state.setRequestCount(0L);
|
||||
state.setRequestCountDate(today);
|
||||
if (persistImmediately) {
|
||||
return adminMetricsStateRepository.save(state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private List<AdminRequestTimelinePoint> buildRequestTimeline(LocalDate metricDate) {
|
||||
Map<Integer, Long> countsByHour = new HashMap<>();
|
||||
for (AdminRequestTimelinePointEntity point : adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(metricDate)) {
|
||||
countsByHour.put(point.getHour(), point.getRequestCount());
|
||||
}
|
||||
return IntStream.range(0, 24)
|
||||
.mapToObj(hour -> new AdminRequestTimelinePoint(hour, formatHourLabel(hour), countsByHour.getOrDefault(hour, 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void incrementRequestTimelinePoint(LocalDate metricDate, int hour) {
|
||||
AdminRequestTimelinePointEntity point = adminRequestTimelinePointRepository
|
||||
.findByMetricDateAndHourForUpdate(metricDate, hour)
|
||||
.orElseGet(() -> createTimelinePoint(metricDate, hour));
|
||||
point.setRequestCount(point.getRequestCount() + 1);
|
||||
adminRequestTimelinePointRepository.save(point);
|
||||
}
|
||||
|
||||
private AdminRequestTimelinePointEntity createTimelinePoint(LocalDate metricDate, int hour) {
|
||||
AdminRequestTimelinePointEntity point = new AdminRequestTimelinePointEntity();
|
||||
point.setMetricDate(metricDate);
|
||||
point.setHour(hour);
|
||||
point.setRequestCount(0L);
|
||||
try {
|
||||
return adminRequestTimelinePointRepository.saveAndFlush(point);
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
return adminRequestTimelinePointRepository.findByMetricDateAndHourForUpdate(metricDate, hour)
|
||||
.orElseThrow(() -> ignored);
|
||||
}
|
||||
}
|
||||
|
||||
private String formatHourLabel(int hour) {
|
||||
return "%02d:00".formatted(hour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminMetricsSnapshot(
|
||||
long requestCount,
|
||||
long downloadTrafficBytes,
|
||||
long transferUsageBytes,
|
||||
long offlineTransferStorageLimitBytes,
|
||||
List<AdminRequestTimelinePoint> requestTimeline
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_admin_metrics_state")
|
||||
public class AdminMetricsState {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_count", nullable = false)
|
||||
private long requestCount;
|
||||
|
||||
@Column(name = "request_count_date")
|
||||
private LocalDate requestCountDate;
|
||||
|
||||
@Column(name = "download_traffic_bytes", nullable = false)
|
||||
private long downloadTrafficBytes;
|
||||
|
||||
@Column(name = "transfer_usage_bytes", nullable = false)
|
||||
private long transferUsageBytes;
|
||||
|
||||
@Column(name = "offline_transfer_storage_limit_bytes", nullable = false)
|
||||
private long offlineTransferStorageLimitBytes;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public long getRequestCount() {
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
public void setRequestCount(long requestCount) {
|
||||
this.requestCount = requestCount;
|
||||
}
|
||||
|
||||
public LocalDate getRequestCountDate() {
|
||||
return requestCountDate;
|
||||
}
|
||||
|
||||
public void setRequestCountDate(LocalDate requestCountDate) {
|
||||
this.requestCountDate = requestCountDate;
|
||||
}
|
||||
|
||||
public long getDownloadTrafficBytes() {
|
||||
return downloadTrafficBytes;
|
||||
}
|
||||
|
||||
public void setDownloadTrafficBytes(long downloadTrafficBytes) {
|
||||
this.downloadTrafficBytes = downloadTrafficBytes;
|
||||
}
|
||||
|
||||
public long getTransferUsageBytes() {
|
||||
return transferUsageBytes;
|
||||
}
|
||||
|
||||
public void setTransferUsageBytes(long transferUsageBytes) {
|
||||
this.transferUsageBytes = transferUsageBytes;
|
||||
}
|
||||
|
||||
public long getOfflineTransferStorageLimitBytes() {
|
||||
return offlineTransferStorageLimitBytes;
|
||||
}
|
||||
|
||||
public void setOfflineTransferStorageLimitBytes(long offlineTransferStorageLimitBytes) {
|
||||
this.offlineTransferStorageLimitBytes = offlineTransferStorageLimitBytes;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AdminMetricsStateRepository extends JpaRepository<AdminMetricsState, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select state from AdminMetricsState state where state.id = :id")
|
||||
Optional<AdminMetricsState> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminOfflineTransferStorageLimitResponse(
|
||||
long offlineTransferStorageLimitBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public record AdminOfflineTransferStorageLimitUpdateRequest(
|
||||
@Positive(message = "离线快传存储上限必须大于 0")
|
||||
long offlineTransferStorageLimitBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminRequestTimelinePoint(
|
||||
int hour,
|
||||
String label,
|
||||
long requestCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "portal_admin_request_timeline_point",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_admin_request_timeline_date_hour", columnNames = {"metric_date", "metric_hour"})
|
||||
)
|
||||
public class AdminRequestTimelinePointEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "metric_date", nullable = false)
|
||||
private LocalDate metricDate;
|
||||
|
||||
@Column(name = "metric_hour", nullable = false)
|
||||
private int hour;
|
||||
|
||||
@Column(name = "request_count", nullable = false)
|
||||
private long requestCount;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDate getMetricDate() {
|
||||
return metricDate;
|
||||
}
|
||||
|
||||
public void setMetricDate(LocalDate metricDate) {
|
||||
this.metricDate = metricDate;
|
||||
}
|
||||
|
||||
public int getHour() {
|
||||
return hour;
|
||||
}
|
||||
|
||||
public void setHour(int hour) {
|
||||
this.hour = hour;
|
||||
}
|
||||
|
||||
public long getRequestCount() {
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
public void setRequestCount(long requestCount) {
|
||||
this.requestCount = requestCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AdminRequestTimelinePointRepository extends JpaRepository<AdminRequestTimelinePointEntity, Long> {
|
||||
|
||||
List<AdminRequestTimelinePointEntity> findAllByMetricDateOrderByHourAsc(LocalDate metricDate);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select point from AdminRequestTimelinePointEntity point
|
||||
where point.metricDate = :metricDate and point.hour = :hour
|
||||
""")
|
||||
Optional<AdminRequestTimelinePointEntity> findByMetricDateAndHourForUpdate(@Param("metricDate") LocalDate metricDate,
|
||||
@Param("hour") int hour);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -21,6 +22,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -34,12 +36,22 @@ public class AdminService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
AdminMetricsSnapshot metrics = adminMetricsService.getSnapshot();
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
storedFileRepository.sumAllFileSize(),
|
||||
metrics.downloadTrafficBytes(),
|
||||
metrics.requestCount(),
|
||||
metrics.transferUsageBytes(),
|
||||
offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now()),
|
||||
metrics.offlineTransferStorageLimitBytes(),
|
||||
metrics.requestTimeline(),
|
||||
registrationInviteService.getCurrentInviteCode()
|
||||
);
|
||||
}
|
||||
@@ -94,7 +106,7 @@ public class AdminService {
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserPassword(Long userId, String newPassword) {
|
||||
if (!PasswordPolicy.isStrong(newPassword)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符");
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, PasswordPolicy.VALIDATION_MESSAGE);
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
@@ -124,7 +136,13 @@ public class AdminService {
|
||||
return new AdminPasswordResetResponse(temporaryPassword);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminOfflineTransferStorageLimitResponse updateOfflineTransferStorageLimit(long offlineTransferStorageLimitBytes) {
|
||||
return adminMetricsService.updateOfflineTransferStorageLimit(offlineTransferStorageLimitBytes);
|
||||
}
|
||||
|
||||
private AdminUserResponse toUserResponse(User user) {
|
||||
long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
|
||||
return new AdminUserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
@@ -133,6 +151,7 @@ public class AdminService {
|
||||
user.getCreatedAt(),
|
||||
user.getRole(),
|
||||
user.isBanned(),
|
||||
usedStorageBytes,
|
||||
user.getStorageQuotaBytes(),
|
||||
user.getMaxUploadSizeBytes()
|
||||
);
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles,
|
||||
long totalStorageBytes,
|
||||
long downloadTrafficBytes,
|
||||
long requestCount,
|
||||
long transferUsageBytes,
|
||||
long offlineTransferStorageBytes,
|
||||
long offlineTransferStorageLimitBytes,
|
||||
List<AdminRequestTimelinePoint> requestTimeline,
|
||||
String inviteCode
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AdminUserPasswordUpdateRequest(
|
||||
@NotBlank
|
||||
@Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
@Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE)
|
||||
String newPassword
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
@AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(newPassword);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public record AdminUserResponse(
|
||||
LocalDateTime createdAt,
|
||||
UserRole role,
|
||||
boolean banned,
|
||||
long usedStorageBytes,
|
||||
long storageQuotaBytes,
|
||||
long maxUploadSizeBytes
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiRequestMetricsFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return HttpMethod.OPTIONS.matches(request.getMethod()) || path == null || !path.startsWith("/api/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
adminMetricsService.incrementRequestCount();
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,26 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
public final class PasswordPolicy {
|
||||
public static final int MIN_LENGTH = 8;
|
||||
public static final String VALIDATION_MESSAGE = "密码至少8位,且必须包含大写字母";
|
||||
|
||||
private PasswordPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isStrong(String password) {
|
||||
if (password == null || password.length() < 10) {
|
||||
if (password == null || password.length() < MIN_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasLower = false;
|
||||
boolean hasUpper = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecial = false;
|
||||
|
||||
for (int i = 0; i < password.length(); i += 1) {
|
||||
char c = password.charAt(i);
|
||||
if (Character.isLowerCase(c)) {
|
||||
hasLower = true;
|
||||
} else if (Character.isUpperCase(c)) {
|
||||
if (Character.isUpperCase(c)) {
|
||||
hasUpper = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else {
|
||||
hasSpecial = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasLower && hasUpper && hasDigit && hasSpecial;
|
||||
return hasUpper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ public record RegisterRequest(
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
|
||||
String phoneNumber,
|
||||
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") String password,
|
||||
@NotBlank @Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE) String password,
|
||||
@NotBlank String confirmPassword,
|
||||
@NotBlank(message = "请输入邀请码") String inviteCode
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
@AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(password);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import jakarta.validation.constraints.Size;
|
||||
public record UpdateUserPasswordRequest(
|
||||
@NotBlank String currentPassword,
|
||||
@NotBlank
|
||||
@Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
@Size(min = PasswordPolicy.MIN_LENGTH, max = 64, message = PasswordPolicy.VALIDATION_MESSAGE)
|
||||
String newPassword
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
@AssertTrue(message = PasswordPolicy.VALIDATION_MESSAGE)
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(newPassword);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import com.yoyuzh.files.storage.LocalFileContentStorage;
|
||||
import com.yoyuzh.files.storage.OssFileContentStorage;
|
||||
import com.yoyuzh.files.storage.S3FileContentStorage;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@@ -11,8 +11,8 @@ public class FileStorageConfiguration {
|
||||
|
||||
@Bean
|
||||
public FileContentStorage fileContentStorage(FileStorageProperties properties) {
|
||||
if ("oss".equalsIgnoreCase(properties.getProvider())) {
|
||||
return new OssFileContentStorage(properties);
|
||||
if ("s3".equalsIgnoreCase(properties.getProvider())) {
|
||||
return new S3FileContentStorage(properties);
|
||||
}
|
||||
return new LocalFileContentStorage(properties);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public class FileStorageProperties {
|
||||
|
||||
private String provider = "local";
|
||||
private final Local local = new Local();
|
||||
private final Oss oss = new Oss();
|
||||
private final S3 s3 = new S3();
|
||||
private long maxFileSize = 500L * 1024 * 1024L;
|
||||
|
||||
public String getProvider() {
|
||||
@@ -22,8 +22,8 @@ public class FileStorageProperties {
|
||||
return local;
|
||||
}
|
||||
|
||||
public Oss getOss() {
|
||||
return oss;
|
||||
public S3 getS3() {
|
||||
return s3;
|
||||
}
|
||||
|
||||
public long getMaxFileSize() {
|
||||
@@ -55,60 +55,60 @@ public class FileStorageProperties {
|
||||
}
|
||||
}
|
||||
|
||||
public static class Oss {
|
||||
private String endpoint;
|
||||
private String bucket;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String publicBaseUrl;
|
||||
private boolean privateBucket = true;
|
||||
public static class S3 {
|
||||
private String apiBaseUrl = "https://api.dogecloud.com";
|
||||
private String apiAccessKey;
|
||||
private String apiSecretKey;
|
||||
private String scope;
|
||||
private int ttlSeconds = 3600;
|
||||
private String region = "automatic";
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
public String getApiBaseUrl() {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
public void setApiBaseUrl(String apiBaseUrl) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
public String getBucket() {
|
||||
return bucket;
|
||||
public String getApiAccessKey() {
|
||||
return apiAccessKey;
|
||||
}
|
||||
|
||||
public void setBucket(String bucket) {
|
||||
this.bucket = bucket;
|
||||
public void setApiAccessKey(String apiAccessKey) {
|
||||
this.apiAccessKey = apiAccessKey;
|
||||
}
|
||||
|
||||
public String getAccessKeyId() {
|
||||
return accessKeyId;
|
||||
public String getApiSecretKey() {
|
||||
return apiSecretKey;
|
||||
}
|
||||
|
||||
public void setAccessKeyId(String accessKeyId) {
|
||||
this.accessKeyId = accessKeyId;
|
||||
public void setApiSecretKey(String apiSecretKey) {
|
||||
this.apiSecretKey = apiSecretKey;
|
||||
}
|
||||
|
||||
public String getAccessKeySecret() {
|
||||
return accessKeySecret;
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setAccessKeySecret(String accessKeySecret) {
|
||||
this.accessKeySecret = accessKeySecret;
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
public String getPublicBaseUrl() {
|
||||
return publicBaseUrl;
|
||||
public int getTtlSeconds() {
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
public void setPublicBaseUrl(String publicBaseUrl) {
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
public void setTtlSeconds(int ttlSeconds) {
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
public boolean isPrivateBucket() {
|
||||
return privateBucket;
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setPrivateBucket(boolean privateBucket) {
|
||||
this.privateBucket = privateBucket;
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.admin.ApiRequestMetricsFilter;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
@@ -35,6 +36,7 @@ import java.util.List;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final ApiRequestMetricsFilter apiRequestMetricsFilter;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CorsProperties corsProperties;
|
||||
@@ -72,6 +74,7 @@ public class SecurityConfig {
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足"));
|
||||
}))
|
||||
.addFilterBefore(apiRequestMetricsFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
@@ -38,15 +39,18 @@ public class FileService {
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final FileShareLinkRepository fileShareLinkRepository;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final long maxFileSize;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileShareLinkRepository fileShareLinkRepository,
|
||||
AdminMetricsService adminMetricsService,
|
||||
FileStorageProperties properties) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.fileShareLinkRepository = fileShareLinkRepository;
|
||||
this.adminMetricsService = adminMetricsService;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
}
|
||||
|
||||
@@ -346,6 +350,7 @@ public class FileService {
|
||||
if (storedFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
|
||||
}
|
||||
adminMetricsService.recordDownloadTraffic(storedFile.getSize());
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return new DownloadUrlResponse(fileContentStorage.createDownloadUrl(
|
||||
|
||||
@@ -71,5 +71,12 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
""")
|
||||
long sumFileSizeByUserId(@Param("userId") Long userId);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(f.size), 0)
|
||||
from StoredFile f
|
||||
where f.directory = false
|
||||
""")
|
||||
long sumAllFileSize();
|
||||
|
||||
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,12 @@ public interface OfflineTransferSessionRepository extends JpaRepository<OfflineT
|
||||
where session.expiresAt < :now
|
||||
""")
|
||||
List<OfflineTransferSession> findAllExpiredWithFiles(@Param("now") Instant now);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(file.size), 0)
|
||||
from OfflineTransferFile file
|
||||
join file.session session
|
||||
where file.uploaded = true and session.expiresAt >= :now
|
||||
""")
|
||||
long sumUploadedFileSizeByExpiresAtAfter(@Param("now") Instant now);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.transfer;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
@@ -36,23 +37,27 @@ public class TransferService {
|
||||
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final FileService fileService;
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final long maxFileSize;
|
||||
|
||||
public TransferService(TransferSessionStore sessionStore,
|
||||
OfflineTransferSessionRepository offlineTransferSessionRepository,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileService fileService,
|
||||
AdminMetricsService adminMetricsService,
|
||||
FileStorageProperties properties) {
|
||||
this.sessionStore = sessionStore;
|
||||
this.offlineTransferSessionRepository = offlineTransferSessionRepository;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.fileService = fileService;
|
||||
this.adminMetricsService = adminMetricsService;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TransferSessionResponse createSession(User sender, CreateTransferSessionRequest request) {
|
||||
pruneExpiredSessions();
|
||||
adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum());
|
||||
if (request.mode() == TransferMode.OFFLINE) {
|
||||
return createOfflineSession(sender, request);
|
||||
}
|
||||
@@ -104,6 +109,11 @@ public class TransferService {
|
||||
if (multipartFile.getSize() != targetFile.getSize()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "离线文件大小与会话清单不一致");
|
||||
}
|
||||
long currentOfflineStorageBytes = offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(Instant.now());
|
||||
long additionalBytes = targetFile.isUploaded() ? 0L : targetFile.getSize();
|
||||
if (currentOfflineStorageBytes + additionalBytes > adminMetricsService.getOfflineTransferStorageLimitBytes()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传存储空间不足,请联系管理员调整上限");
|
||||
}
|
||||
|
||||
try {
|
||||
fileContentStorage.storeTransferFile(
|
||||
@@ -150,6 +160,7 @@ public class TransferService {
|
||||
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
||||
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
||||
ensureOfflineFileUploaded(file);
|
||||
adminMetricsService.recordDownloadTraffic(file.getSize());
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
String downloadUrl = fileContentStorage.createTransferDownloadUrl(sessionId, file.getStorageName(), file.getFilename());
|
||||
|
||||
@@ -32,8 +32,17 @@ app:
|
||||
registration:
|
||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
provider: ${YOYUZH_STORAGE_PROVIDER:local}
|
||||
max-file-size: ${YOYUZH_STORAGE_MAX_FILE_SIZE:524288000}
|
||||
local:
|
||||
root-dir: ${YOYUZH_STORAGE_LOCAL_ROOT_DIR:./storage}
|
||||
s3:
|
||||
api-base-url: ${YOYUZH_DOGECLOUD_API_BASE_URL:https://api.dogecloud.com}
|
||||
api-access-key: ${YOYUZH_DOGECLOUD_API_ACCESS_KEY:}
|
||||
api-secret-key: ${YOYUZH_DOGECLOUD_API_SECRET_KEY:}
|
||||
scope: ${YOYUZH_DOGECLOUD_STORAGE_SCOPE:}
|
||||
ttl-seconds: ${YOYUZH_DOGECLOUD_STORAGE_TTL_SECONDS:3600}
|
||||
region: ${YOYUZH_DOGECLOUD_S3_REGION:automatic}
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.admin.AdminMetricsStateRepository;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -44,9 +50,15 @@ class AdminControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Autowired
|
||||
private OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
@Autowired
|
||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
|
||||
private User portalUser;
|
||||
private User secondaryUser;
|
||||
@@ -55,14 +67,16 @@ class AdminControllerIntegrationTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
offlineTransferSessionRepository.deleteAll();
|
||||
storedFileRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
adminMetricsStateRepository.deleteAll();
|
||||
|
||||
portalUser = new User();
|
||||
portalUser.setUsername("alice");
|
||||
portalUser.setEmail("alice@example.com");
|
||||
portalUser.setPhoneNumber("13800138000");
|
||||
portalUser.setPasswordHash("encoded-password");
|
||||
portalUser.setPasswordHash(passwordEncoder.encode("OriginalA"));
|
||||
portalUser.setCreatedAt(LocalDateTime.now());
|
||||
portalUser = userRepository.save(portalUser);
|
||||
|
||||
@@ -70,7 +84,7 @@ class AdminControllerIntegrationTest {
|
||||
secondaryUser.setUsername("bob");
|
||||
secondaryUser.setEmail("bob@example.com");
|
||||
secondaryUser.setPhoneNumber("13900139000");
|
||||
secondaryUser.setPasswordHash("encoded-password");
|
||||
secondaryUser.setPasswordHash(passwordEncoder.encode("OriginalB"));
|
||||
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
||||
secondaryUser = userRepository.save(secondaryUser);
|
||||
|
||||
@@ -100,6 +114,8 @@ class AdminControllerIntegrationTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin")
|
||||
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
|
||||
int currentHour = LocalTime.now().getHour();
|
||||
|
||||
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
@@ -107,6 +123,7 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
|
||||
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
|
||||
.andExpect(jsonPath("$.data.items[0].banned").value(false))
|
||||
.andExpect(jsonPath("$.data.items[0].usedStorageBytes").value(1024L))
|
||||
.andExpect(jsonPath("$.data.items[0].storageQuotaBytes").isNumber())
|
||||
.andExpect(jsonPath("$.data.items[0].maxUploadSizeBytes").isNumber());
|
||||
|
||||
@@ -114,6 +131,16 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.totalUsers").value(2))
|
||||
.andExpect(jsonPath("$.data.totalFiles").value(2))
|
||||
.andExpect(jsonPath("$.data.totalStorageBytes").value(1280L))
|
||||
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1)))
|
||||
.andExpect(jsonPath("$.data.requestTimeline.length()").value(24))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].hour").value(currentHour))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour)))
|
||||
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1)))
|
||||
.andExpect(jsonPath("$.data.transferUsageBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L))
|
||||
.andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber())
|
||||
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
|
||||
}
|
||||
|
||||
@@ -150,7 +177,7 @@ class AdminControllerIntegrationTest {
|
||||
mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId())
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{"newPassword":"AdminSetPass1!"}
|
||||
{"newPassword":"AdminPass"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(portalUser.getId()));
|
||||
@@ -173,11 +200,81 @@ class AdminControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.data.id").value(portalUser.getId()))
|
||||
.andExpect(jsonPath("$.data.maxUploadSizeBytes").value(10485760L));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/settings/offline-transfer-storage-limit")
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{"offlineTransferStorageLimitBytes":2147483648}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").value(2147483648L));
|
||||
|
||||
mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin")
|
||||
void shouldInvalidateOldPasswordAfterAdminPasswordUpdate() throws Exception {
|
||||
mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId())
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{"newPassword":"AdminPass"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(portalUser.getId()));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{
|
||||
"username": "alice",
|
||||
"password": "OriginalA"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.msg").value("用户名或密码错误"));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{
|
||||
"username": "alice",
|
||||
"password": "AdminPass"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.user.username").value("alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeTrafficAndTransferMetricsInSummary() throws Exception {
|
||||
mockMvc.perform(get("/api/files/download/{fileId}/url", storedFile.getId())
|
||||
.with(user("alice")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.url").value("/api/files/download/" + storedFile.getId()));
|
||||
|
||||
mockMvc.perform(post("/api/transfer/sessions")
|
||||
.with(user("alice"))
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{
|
||||
"mode": "OFFLINE",
|
||||
"files": [
|
||||
{"name": "offline.txt", "relativePath": "资料/offline.txt", "size": 13, "contentType": "text/plain"}
|
||||
]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.mode").value("OFFLINE"));
|
||||
|
||||
mockMvc.perform(get("/api/admin/summary").with(user("admin")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(1024L))
|
||||
.andExpect(jsonPath("$.data.transferUsageBytes").value(13L))
|
||||
.andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin")
|
||||
void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
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.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminMetricsServiceTest {
|
||||
|
||||
@Mock
|
||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
@Mock
|
||||
private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
|
||||
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResetDailyRequestCountWhenSnapshotReadsPreviousDayState() {
|
||||
AdminMetricsState state = new AdminMetricsState();
|
||||
state.setId(1L);
|
||||
state.setRequestCount(42L);
|
||||
state.setRequestCountDate(LocalDate.now().minusDays(1));
|
||||
state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024);
|
||||
|
||||
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state));
|
||||
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of());
|
||||
|
||||
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
|
||||
|
||||
assertThat(snapshot.requestCount()).isZero();
|
||||
assertThat(state.getRequestCount()).isZero();
|
||||
assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now());
|
||||
assertThat(snapshot.requestTimeline()).hasSize(24);
|
||||
assertThat(snapshot.requestTimeline().get(0)).isEqualTo(new AdminRequestTimelinePoint(0, "00:00", 0L));
|
||||
verify(adminMetricsStateRepository).save(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartNewDayRequestCountAtOneWhenIncrementingPreviousDayState() {
|
||||
AdminMetricsState state = new AdminMetricsState();
|
||||
state.setId(1L);
|
||||
state.setRequestCount(42L);
|
||||
state.setRequestCountDate(LocalDate.now().minusDays(1));
|
||||
state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024);
|
||||
|
||||
when(adminMetricsStateRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(state));
|
||||
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(adminRequestTimelinePointRepository.findByMetricDateAndHourForUpdate(LocalDate.now(), LocalTime.now().getHour()))
|
||||
.thenReturn(Optional.empty());
|
||||
when(adminRequestTimelinePointRepository.save(any(AdminRequestTimelinePointEntity.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(adminRequestTimelinePointRepository.saveAndFlush(any(AdminRequestTimelinePointEntity.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
adminMetricsService.incrementRequestCount();
|
||||
|
||||
assertThat(state.getRequestCount()).isEqualTo(1L);
|
||||
assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now());
|
||||
verify(adminMetricsStateRepository).save(state);
|
||||
verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -48,6 +49,10 @@ class AdminServiceTest {
|
||||
private RefreshTokenService refreshTokenService;
|
||||
@Mock
|
||||
private RegistrationInviteService registrationInviteService;
|
||||
@Mock
|
||||
private OfflineTransferSessionRepository offlineTransferSessionRepository;
|
||||
@Mock
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
private AdminService adminService;
|
||||
|
||||
@@ -55,7 +60,8 @@ class AdminServiceTest {
|
||||
void setUp() {
|
||||
adminService = new AdminService(
|
||||
userRepository, storedFileRepository, fileService,
|
||||
passwordEncoder, refreshTokenService, registrationInviteService);
|
||||
passwordEncoder, refreshTokenService, registrationInviteService,
|
||||
offlineTransferSessionRepository, adminMetricsService);
|
||||
}
|
||||
|
||||
// --- getSummary ---
|
||||
@@ -64,12 +70,34 @@ class AdminServiceTest {
|
||||
void shouldReturnSummaryWithCountsAndInviteCode() {
|
||||
when(userRepository.count()).thenReturn(5L);
|
||||
when(storedFileRepository.count()).thenReturn(42L);
|
||||
when(storedFileRepository.sumAllFileSize()).thenReturn(8192L);
|
||||
when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot(
|
||||
0L,
|
||||
0L,
|
||||
0L,
|
||||
20L * 1024 * 1024 * 1024,
|
||||
List.of(
|
||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||
)
|
||||
));
|
||||
when(offlineTransferSessionRepository.sumUploadedFileSizeByExpiresAtAfter(any())).thenReturn(0L);
|
||||
when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001");
|
||||
|
||||
AdminSummaryResponse summary = adminService.getSummary();
|
||||
|
||||
assertThat(summary.totalUsers()).isEqualTo(5L);
|
||||
assertThat(summary.totalFiles()).isEqualTo(42L);
|
||||
assertThat(summary.totalStorageBytes()).isEqualTo(8192L);
|
||||
assertThat(summary.downloadTrafficBytes()).isZero();
|
||||
assertThat(summary.requestCount()).isZero();
|
||||
assertThat(summary.transferUsageBytes()).isZero();
|
||||
assertThat(summary.offlineTransferStorageBytes()).isZero();
|
||||
assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L);
|
||||
assertThat(summary.requestTimeline()).containsExactly(
|
||||
new AdminRequestTimelinePoint(0, "00:00", 0L),
|
||||
new AdminRequestTimelinePoint(1, "01:00", 3L)
|
||||
);
|
||||
assertThat(summary.inviteCode()).isEqualTo("INV-001");
|
||||
}
|
||||
|
||||
@@ -80,11 +108,13 @@ class AdminServiceTest {
|
||||
User user = createUser(1L, "alice", "alice@example.com");
|
||||
when(userRepository.searchByUsernameOrEmail(anyString(), any()))
|
||||
.thenReturn(new PageImpl<>(List.of(user)));
|
||||
when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L);
|
||||
|
||||
PageResponse<AdminUserResponse> response = adminService.listUsers(0, 10, "alice");
|
||||
|
||||
assertThat(response.items()).hasSize(1);
|
||||
assertThat(response.items().get(0).username()).isEqualTo("alice");
|
||||
assertThat(response.items().get(0).usedStorageBytes()).isEqualTo(2048L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -205,7 +235,7 @@ class AdminServiceTest {
|
||||
void shouldRejectWeakPasswordWhenUpdating() {
|
||||
assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("密码至少10位");
|
||||
.hasMessageContaining("密码至少8位");
|
||||
verify(userRepository, never()).findById(any());
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class AuthControllerValidationTest {
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(1000))
|
||||
.andExpect(jsonPath("$.msg").value("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"));
|
||||
.andExpect(jsonPath("$.msg").value("密码至少8位,且必须包含大写字母"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -14,49 +14,34 @@ class PasswordPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPasswordShorterThanTenCharacters() {
|
||||
assertThat(PasswordPolicy.isStrong("Abc1!defg")).isFalse(); // 9 chars
|
||||
void shouldRejectPasswordShorterThanEightCharacters() {
|
||||
assertThat(PasswordPolicy.isStrong("Abcdefg")).isFalse(); // 7 chars
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptPasswordWithExactlyTenCharacters() {
|
||||
assertThat(PasswordPolicy.isStrong("Abcdefg1!x")).isTrue(); // 10 chars
|
||||
void shouldAcceptPasswordWithExactlyEightCharacters() {
|
||||
assertThat(PasswordPolicy.isStrong("Abcdefgh")).isTrue(); // 8 chars
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPasswordMissingUppercase() {
|
||||
assertThat(PasswordPolicy.isStrong("abcdefg1!x")).isFalse();
|
||||
assertThat(PasswordPolicy.isStrong("abcdefgh")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPasswordMissingLowercase() {
|
||||
assertThat(PasswordPolicy.isStrong("ABCDEFG1!X")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPasswordMissingDigit() {
|
||||
assertThat(PasswordPolicy.isStrong("Abcdefgh!x")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPasswordMissingSpecialCharacter() {
|
||||
assertThat(PasswordPolicy.isStrong("Abcdefg12x")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptStrongPasswordWithAllRequirements() {
|
||||
assertThat(PasswordPolicy.isStrong("MyP@ssw0rd!")).isTrue();
|
||||
void shouldAcceptPasswordThatOnlyNeedsUppercaseAndLength() {
|
||||
assertThat(PasswordPolicy.isStrong("ABCDEFGH")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "short", "nouppercase1!", "NOLOWERCASE1!", "NoSpecialChar1", "NoDigit!AbcXyz"})
|
||||
@ValueSource(strings = {"", "short", "noupper", "abcdefghi"})
|
||||
void shouldRejectWeakPasswords(String password) {
|
||||
assertThat(PasswordPolicy.isStrong(password)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptLongPasswordWithAllRequirements() {
|
||||
assertThat(PasswordPolicy.isStrong("MyV3ryStr0ng&SecureP@ssword2024!")).isTrue();
|
||||
void shouldAcceptLongPasswordWithUppercase() {
|
||||
assertThat(PasswordPolicy.isStrong("MyVerySimplePassword")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -26,7 +26,7 @@ class RegisterRequestValidationTest {
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(violation -> violation.getMessage())
|
||||
.contains("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符");
|
||||
.contains("密码至少8位,且必须包含大写字母");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -35,8 +35,8 @@ class RegisterRequestValidationTest {
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"13800138000",
|
||||
"StrongPass1!",
|
||||
"StrongPass1!",
|
||||
"Abcdefgh",
|
||||
"Abcdefgh",
|
||||
"invite-code"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.admin.ApiRequestMetricsFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
@@ -23,6 +24,7 @@ class SecurityConfigTest {
|
||||
corsProperties.setAllowedOrigins(java.util.List.of("https://yoyuzh.xyz"));
|
||||
|
||||
SecurityConfig securityConfig = new SecurityConfig(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new ObjectMapper(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
@@ -34,6 +35,8 @@ class FileServiceEdgeCaseTest {
|
||||
private FileContentStorage fileContentStorage;
|
||||
@Mock
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
@Mock
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
private FileService fileService;
|
||||
|
||||
@@ -41,7 +44,7 @@ class FileServiceEdgeCaseTest {
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
|
||||
}
|
||||
|
||||
// --- normalizeDirectoryPath edge cases ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.admin.AdminMetricsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
@@ -46,6 +47,8 @@ class FileServiceTest {
|
||||
|
||||
@Mock
|
||||
private FileShareLinkRepository fileShareLinkRepository;
|
||||
@Mock
|
||||
private AdminMetricsService adminMetricsService;
|
||||
|
||||
private FileService fileService;
|
||||
|
||||
@@ -53,7 +56,7 @@ class FileServiceTest {
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setMaxFileSize(500L * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties);
|
||||
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user