Migrate storage to DogeCloud and expand admin dashboard

This commit is contained in:
yoyuzh
2026-04-02 12:20:50 +08:00
parent 2424fbd2a7
commit 97edc4cc32
65 changed files with 2842 additions and 380 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.admin;
public record AdminOfflineTransferStorageLimitResponse(
long offlineTransferStorageLimitBytes
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.Positive;
public record AdminOfflineTransferStorageLimitUpdateRequest(
@Positive(message = "离线快传存储上限必须大于 0")
long offlineTransferStorageLimitBytes
) {
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.admin;
public record AdminRequestTimelinePoint(
int hour,
String label,
long requestCount
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ public record AdminUserResponse(
LocalDateTime createdAt,
UserRole role,
boolean banned,
long usedStorageBytes,
long storageQuotaBytes,
long maxUploadSizeBytes
) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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