修改后台权限

This commit is contained in:
yoyuzh
2026-03-24 14:30:59 +08:00
parent 00f902f475
commit b2d9db7be9
9310 changed files with 1246063 additions and 48 deletions

View File

@@ -68,6 +68,18 @@ public class AdminController {
return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword()));
}
@PatchMapping("/users/{userId}/storage-quota")
public ApiResponse<AdminUserResponse> updateUserStorageQuota(@PathVariable Long userId,
@Valid @RequestBody AdminUserStorageQuotaUpdateRequest request) {
return ApiResponse.success(adminService.updateUserStorageQuota(userId, request.storageQuotaBytes()));
}
@PatchMapping("/users/{userId}/max-upload-size")
public ApiResponse<AdminUserResponse> updateUserMaxUploadSize(@PathVariable Long userId,
@Valid @RequestBody AdminUserMaxUploadSizeUpdateRequest request) {
return ApiResponse.success(adminService.updateUserMaxUploadSize(userId, request.maxUploadSizeBytes()));
}
@PostMapping("/users/{userId}/password/reset")
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
return ApiResponse.success(adminService.resetUserPassword(userId));

View File

@@ -103,6 +103,20 @@ public class AdminService {
return toUserResponse(userRepository.save(user));
}
@Transactional
public AdminUserResponse updateUserStorageQuota(Long userId, long storageQuotaBytes) {
User user = getRequiredUser(userId);
user.setStorageQuotaBytes(storageQuotaBytes);
return toUserResponse(userRepository.save(user));
}
@Transactional
public AdminUserResponse updateUserMaxUploadSize(Long userId, long maxUploadSizeBytes) {
User user = getRequiredUser(userId);
user.setMaxUploadSizeBytes(maxUploadSizeBytes);
return toUserResponse(userRepository.save(user));
}
@Transactional
public AdminPasswordResetResponse resetUserPassword(Long userId) {
String temporaryPassword = generateTemporaryPassword();
@@ -118,7 +132,9 @@ public class AdminService {
user.getPhoneNumber(),
user.getCreatedAt(),
user.getRole(),
user.isBanned()
user.isBanned(),
user.getStorageQuotaBytes(),
user.getMaxUploadSizeBytes()
);
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record AdminUserMaxUploadSizeUpdateRequest(
@NotNull
@Positive
Long maxUploadSizeBytes
) {
}

View File

@@ -11,6 +11,8 @@ public record AdminUserResponse(
String phoneNumber,
LocalDateTime createdAt,
UserRole role,
boolean banned
boolean banned,
long storageQuotaBytes,
long maxUploadSizeBytes
) {
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record AdminUserStorageQuotaUpdateRequest(
@NotNull
@Positive
Long storageQuotaBytes
) {
}

View File

@@ -261,7 +261,9 @@ public class AuthService {
user.getPreferredLanguage(),
buildAvatarUrl(user),
user.getRole(),
user.getCreatedAt()
user.getCreatedAt(),
user.getStorageQuotaBytes(),
user.getMaxUploadSizeBytes()
);
}

View File

@@ -20,6 +20,8 @@ import java.time.LocalDateTime;
@Index(name = "idx_user_created_at", columnList = "created_at")
})
public class User {
public static final long DEFAULT_STORAGE_QUOTA_BYTES = 50L * 1024 * 1024 * 1024;
public static final long DEFAULT_MAX_UPLOAD_SIZE_BYTES = 500L * 1024 * 1024;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -68,6 +70,12 @@ public class User {
@Column(nullable = false)
private boolean banned;
@Column(name = "storage_quota_bytes")
private Long storageQuotaBytes;
@Column(name = "max_upload_size_bytes")
private Long maxUploadSizeBytes;
@PrePersist
public void prePersist() {
if (createdAt == null) {
@@ -82,6 +90,12 @@ public class User {
if (preferredLanguage == null || preferredLanguage.isBlank()) {
preferredLanguage = "zh-CN";
}
if (storageQuotaBytes == null || storageQuotaBytes <= 0) {
storageQuotaBytes = DEFAULT_STORAGE_QUOTA_BYTES;
}
if (maxUploadSizeBytes == null || maxUploadSizeBytes <= 0) {
maxUploadSizeBytes = DEFAULT_MAX_UPLOAD_SIZE_BYTES;
}
}
public Long getId() {
@@ -203,4 +217,26 @@ public class User {
public void setBanned(boolean banned) {
this.banned = banned;
}
public long getStorageQuotaBytes() {
if (storageQuotaBytes == null || storageQuotaBytes <= 0) {
return DEFAULT_STORAGE_QUOTA_BYTES;
}
return storageQuotaBytes;
}
public void setStorageQuotaBytes(Long storageQuotaBytes) {
this.storageQuotaBytes = storageQuotaBytes;
}
public long getMaxUploadSizeBytes() {
if (maxUploadSizeBytes == null || maxUploadSizeBytes <= 0) {
return DEFAULT_MAX_UPLOAD_SIZE_BYTES;
}
return maxUploadSizeBytes;
}
public void setMaxUploadSizeBytes(Long maxUploadSizeBytes) {
this.maxUploadSizeBytes = maxUploadSizeBytes;
}
}

View File

@@ -1,6 +1,7 @@
package com.yoyuzh.auth.dto;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.auth.User;
import java.time.LocalDateTime;
@@ -14,9 +15,24 @@ public record UserProfileResponse(
String preferredLanguage,
String avatarUrl,
UserRole role,
LocalDateTime createdAt
LocalDateTime createdAt,
long storageQuotaBytes,
long maxUploadSizeBytes
) {
public UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
this(id, username, username, email, null, null, "zh-CN", null, UserRole.USER, createdAt);
this(
id,
username,
username,
email,
null,
null,
"zh-CN",
null,
UserRole.USER,
createdAt,
User.DEFAULT_STORAGE_QUOTA_BYTES,
User.DEFAULT_MAX_UPLOAD_SIZE_BYTES
);
}
}

View File

@@ -54,7 +54,7 @@ public class FileService {
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
String normalizedPath = normalizeDirectoryPath(path);
String filename = normalizeUploadFilename(multipartFile.getOriginalFilename());
validateUpload(user.getId(), normalizedPath, filename, multipartFile.getSize());
validateUpload(user, normalizedPath, filename, multipartFile.getSize());
ensureDirectoryHierarchy(user, normalizedPath);
fileContentStorage.upload(user.getId(), normalizedPath, filename, multipartFile);
@@ -64,7 +64,7 @@ public class FileService {
public InitiateUploadResponse initiateUpload(User user, InitiateUploadRequest request) {
String normalizedPath = normalizeDirectoryPath(request.path());
String filename = normalizeLeafName(request.filename());
validateUpload(user.getId(), normalizedPath, filename, request.size());
validateUpload(user, normalizedPath, filename, request.size());
PreparedUpload preparedUpload = fileContentStorage.prepareUpload(
user.getId(),
@@ -88,7 +88,7 @@ public class FileService {
String normalizedPath = normalizeDirectoryPath(request.path());
String filename = normalizeLeafName(request.filename());
String storageName = normalizeLeafName(request.storageName());
validateUpload(user.getId(), normalizedPath, filename, request.size());
validateUpload(user, normalizedPath, filename, request.size());
ensureDirectoryHierarchy(user, normalizedPath);
fileContentStorage.completeUpload(user.getId(), normalizedPath, storageName, request.contentType(), request.size());
@@ -265,6 +265,7 @@ public class FileService {
}
if (!storedFile.isDirectory()) {
ensureWithinStorageQuota(user, storedFile.getSize());
fileContentStorage.copyFile(user.getId(), storedFile.getPath(), normalizedTargetPath, storedFile.getStorageName());
return toResponse(storedFileRepository.save(copyStoredFile(storedFile, normalizedTargetPath)));
}
@@ -276,6 +277,11 @@ public class FileService {
}
List<StoredFile> descendants = storedFileRepository.findByUserIdAndPathEqualsOrDescendant(user.getId(), oldLogicalPath);
long additionalBytes = descendants.stream()
.filter(descendant -> !descendant.isDirectory())
.mapToLong(StoredFile::getSize)
.sum();
ensureWithinStorageQuota(user, additionalBytes);
List<StoredFile> copiedEntries = new ArrayList<>();
fileContentStorage.ensureDirectory(user.getId(), newLogicalPath);
@@ -421,7 +427,7 @@ public class FileService {
byte[] content) {
String normalizedPath = normalizeDirectoryPath(path);
String normalizedFilename = normalizeLeafName(filename);
validateUpload(recipient.getId(), normalizedPath, normalizedFilename, size);
validateUpload(recipient, normalizedPath, normalizedFilename, size);
ensureDirectoryHierarchy(recipient, normalizedPath);
fileContentStorage.storeImportedFile(
recipient.getId(),
@@ -510,13 +516,27 @@ public class FileService {
return storedFile;
}
private void validateUpload(Long userId, String normalizedPath, String filename, long size) {
if (size > maxFileSize) {
private void validateUpload(User user, String normalizedPath, String filename, long size) {
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(userId, normalizedPath, filename)) {
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
ensureWithinStorageQuota(user, size);
}
private void ensureWithinStorageQuota(User user, long additionalBytes) {
if (additionalBytes <= 0) {
return;
}
long usedBytes = storedFileRepository.sumFileSizeByUserId(user.getId());
long quotaBytes = user.getStorageQuotaBytes();
if (usedBytes > Long.MAX_VALUE - additionalBytes || usedBytes + additionalBytes > quotaBytes) {
throw new BusinessException(ErrorCode.UNKNOWN, "存储空间不足");
}
}
private void ensureDirectoryHierarchy(User user, String normalizedPath) {

View File

@@ -64,5 +64,12 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
List<StoredFile> findByUserIdAndPathEqualsOrDescendant(@Param("userId") Long userId,
@Param("path") String path);
@Query("""
select coalesce(sum(f.size), 0)
from StoredFile f
where f.user.id = :userId and f.directory = false
""")
long sumFileSizeByUserId(@Param("userId") Long userId);
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
}

View File

@@ -106,7 +106,9 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
.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].banned").value(false))
.andExpect(jsonPath("$.data.items[0].storageQuotaBytes").isNumber())
.andExpect(jsonPath("$.data.items[0].maxUploadSizeBytes").isNumber());
mockMvc.perform(get("/api/admin/summary"))
.andExpect(status().isOk())
@@ -153,6 +155,24 @@ class AdminControllerIntegrationTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(portalUser.getId()));
mockMvc.perform(patch("/api/admin/users/{userId}/storage-quota", portalUser.getId())
.contentType("application/json")
.content("""
{"storageQuotaBytes":1073741824}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(portalUser.getId()))
.andExpect(jsonPath("$.data.storageQuotaBytes").value(1073741824L));
mockMvc.perform(patch("/api/admin/users/{userId}/max-upload-size", portalUser.getId())
.contentType("application/json")
.content("""
{"maxUploadSizeBytes":10485760}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(portalUser.getId()))
.andExpect(jsonPath("$.data.maxUploadSizeBytes").value(10485760L));
mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty());

View File

@@ -179,6 +179,29 @@ class FileServiceEdgeCaseTest {
.hasMessageContaining("文件大小超出限制");
}
@Test
void shouldRejectUploadExceedingUserMaxUploadSizeLimit() {
User user = createUser(1L);
user.setMaxUploadSizeBytes(1024L);
assertThatThrownBy(() -> fileService.initiateUpload(user,
new InitiateUploadRequest("/docs", "large.bin", "application/octet-stream", 1025L)))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("文件大小超出限制");
}
@Test
void shouldRejectUploadWhenUserStorageQuotaInsufficient() {
User user = createUser(1L);
user.setStorageQuotaBytes(1024L);
when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(900L);
assertThatThrownBy(() -> fileService.initiateUpload(user,
new InitiateUploadRequest("/docs", "quota.bin", "application/octet-stream", 200L)))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("存储空间不足");
}
// --- rename no-op when name unchanged ---
@Test