From 3602a149186612a3c265a68e49446864ffc398fa Mon Sep 17 00:00:00 2001 From: HCDD1 <149296542+HCDD1@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:45:48 +0800 Subject: [PATCH] Improve admin backend list performance --- .../repository/DocumentRepository.java | 23 +- .../backend/repository/UserRepository.java | 16 + .../backend/service/AdminService.java | 287 +++++++++--------- .../backend/service/AdminServiceTest.java | 123 ++++++++ 4 files changed, 299 insertions(+), 150 deletions(-) create mode 100644 backend/src/test/java/com/crystalpdf/backend/service/AdminServiceTest.java diff --git a/backend/src/main/java/com/crystalpdf/backend/repository/DocumentRepository.java b/backend/src/main/java/com/crystalpdf/backend/repository/DocumentRepository.java index 0b74d20..15bdf49 100644 --- a/backend/src/main/java/com/crystalpdf/backend/repository/DocumentRepository.java +++ b/backend/src/main/java/com/crystalpdf/backend/repository/DocumentRepository.java @@ -1,12 +1,31 @@ -package com.crystalpdf.backend.repository; - +package com.crystalpdf.backend.repository; + import com.crystalpdf.backend.entity.Document; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; public interface DocumentRepository extends JpaRepository { List findByOwnerIdOrderByCreatedAtDesc(Long userId); Optional findByIdAndOwnerId(Long id, Long userId); + + @Query(""" + select d.owner.id as ownerId, + count(d) as fileCount, + coalesce(sum(d.sizeBytes), 0) as totalSizeBytes + from Document d + where d.owner.id in :ownerIds + group by d.owner.id + """) + List findStorageStatsByOwnerIds(@Param("ownerIds") Collection ownerIds); + + interface OwnerStorageStats { + Long getOwnerId(); + Long getFileCount(); + Long getTotalSizeBytes(); + } } diff --git a/backend/src/main/java/com/crystalpdf/backend/repository/UserRepository.java b/backend/src/main/java/com/crystalpdf/backend/repository/UserRepository.java index 591cf61..027397b 100644 --- a/backend/src/main/java/com/crystalpdf/backend/repository/UserRepository.java +++ b/backend/src/main/java/com/crystalpdf/backend/repository/UserRepository.java @@ -1,7 +1,11 @@ package com.crystalpdf.backend.repository; import com.crystalpdf.backend.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -10,4 +14,16 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByUsername(String username); boolean existsByUsername(String username); + long countByAdminTrue(); + + @Query(""" + select u from User u + where (:search is null + or lower(u.email) like :search + or lower(coalesce(u.username, '')) like :search) + and (:adminOnly is null or u.admin = :adminOnly) + """) + Page findForAdminList(@Param("search") String search, + @Param("adminOnly") Boolean adminOnly, + Pageable pageable); } diff --git a/backend/src/main/java/com/crystalpdf/backend/service/AdminService.java b/backend/src/main/java/com/crystalpdf/backend/service/AdminService.java index 8234b91..b353fe2 100644 --- a/backend/src/main/java/com/crystalpdf/backend/service/AdminService.java +++ b/backend/src/main/java/com/crystalpdf/backend/service/AdminService.java @@ -1,180 +1,171 @@ -package com.crystalpdf.backend.service; - -import com.crystalpdf.backend.dto.AdminUserResponse; -import com.crystalpdf.backend.dto.AppSettingsResponse; -import com.crystalpdf.backend.dto.UpdateAppSettingsRequest; -import com.crystalpdf.backend.entity.AppSettings; -import com.crystalpdf.backend.entity.Document; -import com.crystalpdf.backend.entity.User; +package com.crystalpdf.backend.service; + +import com.crystalpdf.backend.dto.AdminUserResponse; +import com.crystalpdf.backend.dto.AppSettingsResponse; +import com.crystalpdf.backend.dto.UpdateAppSettingsRequest; +import com.crystalpdf.backend.entity.AppSettings; +import com.crystalpdf.backend.entity.Document; +import com.crystalpdf.backend.entity.User; import com.crystalpdf.backend.repository.AppSettingsRepository; import com.crystalpdf.backend.repository.DocumentRepository; import com.crystalpdf.backend.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import java.io.File; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; - -@Service -public class AdminService { - - private final UserRepository userRepository; - private final DocumentRepository documentRepository; - private final StorageService storageService; - private final AppSettingsRepository settingsRepository; - private final PasswordEncoder passwordEncoder; - - @Value("${crystalpdf.storage.path}") - private String storagePath; - - public AdminService(UserRepository userRepository, DocumentRepository documentRepository, - StorageService storageService, AppSettingsRepository settingsRepository, - PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.documentRepository = documentRepository; - this.storageService = storageService; - this.settingsRepository = settingsRepository; - this.passwordEncoder = passwordEncoder; +import java.util.Map; + +@Service +public class AdminService { + + private final UserRepository userRepository; + private final DocumentRepository documentRepository; + private final StorageService storageService; + private final AppSettingsRepository settingsRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${crystalpdf.storage.path}") + private String storagePath; + + public AdminService(UserRepository userRepository, DocumentRepository documentRepository, + StorageService storageService, AppSettingsRepository settingsRepository, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.documentRepository = documentRepository; + this.storageService = storageService; + this.settingsRepository = settingsRepository; + this.passwordEncoder = passwordEncoder; } public Page getAllUsers(int page, int pageSize, String search, String filter) { - List allUsers = userRepository.findAll(); - - // Apply search filter - if (search != null && !search.isBlank()) { - String q = search.toLowerCase(); - allUsers = allUsers.stream() - .filter(u -> u.getEmail().toLowerCase().contains(q) || - u.getDisplayUsername().toLowerCase().contains(q)) - .toList(); + String searchPattern = search != null && !search.isBlank() + ? "%" + search.toLowerCase() + "%" + : null; + Boolean adminOnly = null; + if ("admin".equalsIgnoreCase(filter)) { + adminOnly = true; + } else if ("regular".equalsIgnoreCase(filter)) { + adminOnly = false; } - // Apply admin filter - if (filter != null && !filter.isBlank()) { - if ("admin".equalsIgnoreCase(filter)) { - allUsers = allUsers.stream().filter(User::isAdmin).toList(); - } else if ("regular".equalsIgnoreCase(filter)) { - allUsers = allUsers.stream().filter(u -> !u.isAdmin()).toList(); - } - } - - // Convert to responses - List responses = allUsers.stream().map(user -> { - List docs = documentRepository.findByOwnerIdOrderByCreatedAtDesc(user.getId()); - long storageUsed = docs.stream().mapToLong(Document::getSizeBytes).sum(); - AppSettings settings = getSettingsEntity(); + Page users = userRepository.findForAdminList(searchPattern, adminOnly, PageRequest.of(page, pageSize)); + List userIds = users.getContent().stream().map(User::getId).toList(); + Map storageStats = userIds.isEmpty() + ? Map.of() + : documentRepository.findStorageStatsByOwnerIds(userIds).stream() + .collect(java.util.stream.Collectors.toMap( + DocumentRepository.OwnerStorageStats::getOwnerId, + stats -> stats + )); + AppSettings settings = getSettingsEntity(); + + return users.map(user -> { + DocumentRepository.OwnerStorageStats stats = storageStats.get(user.getId()); + long storageUsed = stats != null && stats.getTotalSizeBytes() != null ? stats.getTotalSizeBytes() : 0L; + int fileCount = stats != null && stats.getFileCount() != null ? Math.toIntExact(stats.getFileCount()) : 0; long limitBytes = user.getStorageLimitBytes() != null ? user.getStorageLimitBytes() : settings.getDefaultStorageLimitMb() * 1024L * 1024L; return new AdminUserResponse( user.getId(), user.getEmail(), user.getDisplayUsername(), user.isAdmin(), user.isPasswordChangeRequired(), - limitBytes, storageUsed, docs.size(), + limitBytes, storageUsed, fileCount, user.getCreatedAt() != null ? user.getCreatedAt().toString() : "" ); - }).toList(); - - // Apply pagination - int start = page * pageSize; - int end = Math.min(start + pageSize, responses.size()); - List pageContent = responses.subList(start, end); - int totalPages = (int) Math.ceil((double) responses.size() / pageSize); - - return new PageImpl<>(pageContent, PageRequest.of(page, pageSize), responses.size()); - } - - public void setStorageLimit(Long userId, Long limitBytes) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - user.setStorageLimitBytes(limitBytes); - userRepository.save(user); - } - - @Transactional - public void deleteUser(Long userId, User requestingAdmin) { - if (userId.equals(requestingAdmin.getId())) { - throw new IllegalArgumentException("Cannot delete your own admin account."); - } - User target = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - List docs = documentRepository.findByOwnerIdOrderByCreatedAtDesc(target.getId()); - for (Document doc : docs) storageService.deleteFile(doc); - documentRepository.deleteAll(docs); - userRepository.delete(target); + }); } - - public AppSettingsResponse getSettings() { - AppSettings s = getSettingsEntity(); - return new AppSettingsResponse(s.isAllowRegistration(), s.getMaxUploadSizeMb(), s.getDefaultStorageLimitMb(), s.isMaintenanceMode()); - } - - public AppSettingsResponse updateSettings(UpdateAppSettingsRequest req) { - AppSettings s = getSettingsEntity(); - if (req.allowRegistration() != null) s.setAllowRegistration(req.allowRegistration()); - if (req.maxUploadSizeMb() != null) s.setMaxUploadSizeMb(req.maxUploadSizeMb()); - if (req.defaultStorageLimitMb() != null) s.setDefaultStorageLimitMb(req.defaultStorageLimitMb()); - if (req.maintenanceMode() != null) s.setMaintenanceMode(req.maintenanceMode()); - settingsRepository.save(s); - return new AppSettingsResponse(s.isAllowRegistration(), s.getMaxUploadSizeMb(), s.getDefaultStorageLimitMb(), s.isMaintenanceMode()); - } - - /** Toggle admin status of a user. Cannot demote self. */ - public void toggleAdmin(Long userId, User requestingAdmin) { - if (userId.equals(requestingAdmin.getId())) { - throw new IllegalArgumentException("Cannot change your own admin status."); - } - User target = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - target.setAdmin(!target.isAdmin()); - userRepository.save(target); - } - - /** Force a user to change their password on next login. Returns the plaintext temp password. */ - public String forcePasswordReset(Long userId) { - User target = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - String tempPassword = java.util.UUID.randomUUID().toString().substring(0, 12); - target.setPassword(passwordEncoder.encode(tempPassword)); - target.setPasswordChangeRequired(true); - userRepository.save(target); - return tempPassword; - } - - /** Returns platform system information. */ - public Map getSystemInfo() { - Map info = new LinkedHashMap<>(); - - // JVM - Runtime rt = Runtime.getRuntime(); - info.put("jvmMaxMemoryMb", rt.maxMemory() / (1024 * 1024)); - info.put("jvmUsedMemoryMb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024)); - info.put("jvmTotalMemoryMb", rt.totalMemory() / (1024 * 1024)); - info.put("availableProcessors", rt.availableProcessors()); - - // Disk - File storage = new File(storagePath); - info.put("diskTotalMb", storage.getTotalSpace() / (1024 * 1024)); - info.put("diskFreeMb", storage.getFreeSpace() / (1024 * 1024)); - info.put("diskUsableMb", storage.getUsableSpace() / (1024 * 1024)); - + + public void setStorageLimit(Long userId, Long limitBytes) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found.")); + user.setStorageLimitBytes(limitBytes); + userRepository.save(user); + } + + @Transactional + public void deleteUser(Long userId, User requestingAdmin) { + if (userId.equals(requestingAdmin.getId())) { + throw new IllegalArgumentException("Cannot delete your own admin account."); + } + User target = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found.")); + List docs = documentRepository.findByOwnerIdOrderByCreatedAtDesc(target.getId()); + for (Document doc : docs) storageService.deleteFile(doc); + documentRepository.deleteAll(docs); + userRepository.delete(target); + } + + public AppSettingsResponse getSettings() { + AppSettings s = getSettingsEntity(); + return new AppSettingsResponse(s.isAllowRegistration(), s.getMaxUploadSizeMb(), s.getDefaultStorageLimitMb(), s.isMaintenanceMode()); + } + + public AppSettingsResponse updateSettings(UpdateAppSettingsRequest req) { + AppSettings s = getSettingsEntity(); + if (req.allowRegistration() != null) s.setAllowRegistration(req.allowRegistration()); + if (req.maxUploadSizeMb() != null) s.setMaxUploadSizeMb(req.maxUploadSizeMb()); + if (req.defaultStorageLimitMb() != null) s.setDefaultStorageLimitMb(req.defaultStorageLimitMb()); + if (req.maintenanceMode() != null) s.setMaintenanceMode(req.maintenanceMode()); + settingsRepository.save(s); + return new AppSettingsResponse(s.isAllowRegistration(), s.getMaxUploadSizeMb(), s.getDefaultStorageLimitMb(), s.isMaintenanceMode()); + } + + /** Toggle admin status of a user. Cannot demote self. */ + public void toggleAdmin(Long userId, User requestingAdmin) { + if (userId.equals(requestingAdmin.getId())) { + throw new IllegalArgumentException("Cannot change your own admin status."); + } + User target = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found.")); + target.setAdmin(!target.isAdmin()); + userRepository.save(target); + } + + /** Force a user to change their password on next login. Returns the plaintext temp password. */ + public String forcePasswordReset(Long userId) { + User target = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found.")); + String tempPassword = java.util.UUID.randomUUID().toString().substring(0, 12); + target.setPassword(passwordEncoder.encode(tempPassword)); + target.setPasswordChangeRequired(true); + userRepository.save(target); + return tempPassword; + } + + /** Returns platform system information. */ + public Map getSystemInfo() { + Map info = new LinkedHashMap<>(); + + // JVM + Runtime rt = Runtime.getRuntime(); + info.put("jvmMaxMemoryMb", rt.maxMemory() / (1024 * 1024)); + info.put("jvmUsedMemoryMb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024)); + info.put("jvmTotalMemoryMb", rt.totalMemory() / (1024 * 1024)); + info.put("availableProcessors", rt.availableProcessors()); + + // Disk + File storage = new File(storagePath); + info.put("diskTotalMb", storage.getTotalSpace() / (1024 * 1024)); + info.put("diskFreeMb", storage.getFreeSpace() / (1024 * 1024)); + info.put("diskUsableMb", storage.getUsableSpace() / (1024 * 1024)); + // Platform stats info.put("totalUsers", userRepository.count()); info.put("totalFiles", documentRepository.count()); - info.put("totalAdmins", userRepository.findAll().stream().filter(User::isAdmin).count()); + info.put("totalAdmins", userRepository.countByAdminTrue()); info.put("javaVersion", System.getProperty("java.version")); info.put("osName", System.getProperty("os.name")); - return info; - } - - private AppSettings getSettingsEntity() { - return settingsRepository.findById(1L).orElseGet(() -> settingsRepository.save(new AppSettings())); - } -} + return info; + } + + private AppSettings getSettingsEntity() { + return settingsRepository.findById(1L).orElseGet(() -> settingsRepository.save(new AppSettings())); + } +} diff --git a/backend/src/test/java/com/crystalpdf/backend/service/AdminServiceTest.java b/backend/src/test/java/com/crystalpdf/backend/service/AdminServiceTest.java new file mode 100644 index 0000000..6d8b8cf --- /dev/null +++ b/backend/src/test/java/com/crystalpdf/backend/service/AdminServiceTest.java @@ -0,0 +1,123 @@ +package com.crystalpdf.backend.service; + +import com.crystalpdf.backend.dto.AdminUserResponse; +import com.crystalpdf.backend.entity.AppSettings; +import com.crystalpdf.backend.entity.User; +import com.crystalpdf.backend.repository.AppSettingsRepository; +import com.crystalpdf.backend.repository.DocumentRepository; +import com.crystalpdf.backend.repository.UserRepository; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock UserRepository userRepository; + @Mock DocumentRepository documentRepository; + @Mock StorageService storageService; + @Mock AppSettingsRepository settingsRepository; + @Mock PasswordEncoder passwordEncoder; + + private AdminService adminService; + + @BeforeEach + void setUp() { + adminService = new AdminService( + userRepository, + documentRepository, + storageService, + settingsRepository, + passwordEncoder + ); + ReflectionTestUtils.setField(adminService, "storagePath", "."); + } + + @Test + void getAllUsers_usesPagedUsersAndBatchedStorageStats() { + User alice = makeUser(1L, "alice@example.com", "alice", false); + User bob = makeUser(2L, "bob@example.com", "bob", true); + PageRequest pageRequest = PageRequest.of(0, 2); + when(userRepository.findForAdminList("%example%", null, pageRequest)) + .thenReturn(new PageImpl<>(List.of(alice, bob), pageRequest, 20)); + + AppSettings settings = new AppSettings(); + settings.setDefaultStorageLimitMb(100); + when(settingsRepository.findById(1L)).thenReturn(Optional.of(settings)); + when(documentRepository.findStorageStatsByOwnerIds(List.of(1L, 2L))) + .thenReturn(List.of(new Stats(1L, 3L, 1_024L))); + + Page users = adminService.getAllUsers(0, 2, "example", ""); + + assertThat(users.getTotalElements()).isEqualTo(20); + assertThat(users.getContent()).hasSize(2); + assertThat(users.getContent().get(0).id()).isEqualTo(1L); + assertThat(users.getContent().get(0).fileCount()).isEqualTo(3); + assertThat(users.getContent().get(0).storageUsedBytes()).isEqualTo(1_024L); + assertThat(users.getContent().get(1).id()).isEqualTo(2L); + assertThat(users.getContent().get(1).fileCount()).isZero(); + assertThat(users.getContent().get(1).storageUsedBytes()).isZero(); + + verify(userRepository, never()).findAll(); + verify(documentRepository, never()).findByOwnerIdOrderByCreatedAtDesc(any()); + } + + @Test + void getSystemInfo_countsAdminsWithoutLoadingAllUsers() { + when(userRepository.count()).thenReturn(5L); + when(userRepository.countByAdminTrue()).thenReturn(2L); + when(documentRepository.count()).thenReturn(10L); + + var info = adminService.getSystemInfo(); + + assertThat(info).containsEntry("totalUsers", 5L); + assertThat(info).containsEntry("totalFiles", 10L); + assertThat(info).containsEntry("totalAdmins", 2L); + verify(userRepository, never()).findAll(); + } + + private User makeUser(Long id, String email, String username, boolean admin) { + User user = new User(); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "createdAt", LocalDateTime.parse("2026-01-01T00:00:00")); + user.setEmail(email); + user.setDisplayUsername(username); + user.setAdmin(admin); + return user; + } + + private record Stats(Long ownerId, Long fileCount, Long totalSizeBytes) + implements DocumentRepository.OwnerStorageStats { + @Override + public Long getOwnerId() { + return ownerId; + } + + @Override + public Long getFileCount() { + return fileCount; + } + + @Override + public Long getTotalSizeBytes() { + return totalSizeBytes; + } + } +}