diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java b/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java index f17c78a..c01e7d9 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminInspectionQueryService.java @@ -8,6 +8,7 @@ import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.FileEntity; import com.yoyuzh.files.core.FileEntityRepository; import com.yoyuzh.files.core.FileEntityType; +import com.yoyuzh.files.core.FileBlob; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileEntityRepository; import com.yoyuzh.files.core.StoredFileRepository; @@ -23,7 +24,12 @@ import org.springframework.util.StringUtils; import java.time.Instant; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -82,8 +88,15 @@ public class AdminInspectionQueryService { entityType, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) ); - List items = result.getContent().stream() - .map(this::toFileBlobResponse) + List entities = result.getContent(); + Map blobsByObjectKey = loadBlobsByObjectKey(entities); + Map linkStatsByEntityId = loadLinkStatsByEntityId(entities); + List items = entities.stream() + .map(entity -> toFileBlobResponse( + entity, + blobsByObjectKey.get(entity.getObjectKey()), + linkStatsByEntityId.get(entity.getId()) + )) .toList(); return new PageResponse<>(items, result.getTotalElements(), page, size); } @@ -126,10 +139,17 @@ public class AdminInspectionQueryService { ); } - private AdminFileBlobResponse toFileBlobResponse(FileEntity entity) { - var blob = fileBlobRepository.findByObjectKey(entity.getObjectKey()).orElse(null); - long linkedStoredFileCount = storedFileEntityRepository.countByFileEntityId(entity.getId()); - long linkedOwnerCount = storedFileEntityRepository.countDistinctOwnersByFileEntityId(entity.getId()); + private AdminFileBlobResponse toFileBlobResponse(FileEntity entity, + FileBlob blob, + StoredFileEntityRepository.FileEntityLinkStatsProjection linkStats) { + long linkedStoredFileCount = linkStats == null || linkStats.getLinkedStoredFileCount() == null + ? 0L + : linkStats.getLinkedStoredFileCount(); + long linkedOwnerCount = linkStats == null || linkStats.getLinkedOwnerCount() == null + ? 0L + : linkStats.getLinkedOwnerCount(); + String sampleOwnerUsername = linkStats == null ? null : linkStats.getSampleOwnerUsername(); + String sampleOwnerEmail = linkStats == null ? null : linkStats.getSampleOwnerEmail(); return new AdminFileBlobResponse( entity.getId(), blob == null ? null : blob.getId(), @@ -141,8 +161,8 @@ public class AdminInspectionQueryService { entity.getReferenceCount(), linkedStoredFileCount, linkedOwnerCount, - storedFileEntityRepository.findSampleOwnerUsernameByFileEntityId(entity.getId()), - storedFileEntityRepository.findSampleOwnerEmailByFileEntityId(entity.getId()), + sampleOwnerUsername, + sampleOwnerEmail, entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(), entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(), entity.getCreatedAt(), @@ -153,6 +173,37 @@ public class AdminInspectionQueryService { ); } + private Map loadBlobsByObjectKey(List entities) { + Set objectKeys = entities.stream() + .map(FileEntity::getObjectKey) + .filter(StringUtils::hasText) + .collect(Collectors.toSet()); + if (objectKeys.isEmpty()) { + return Map.of(); + } + return fileBlobRepository.findAllByObjectKeyIn(objectKeys).stream() + .collect(Collectors.toMap( + FileBlob::getObjectKey, + Function.identity(), + (left, right) -> left + )); + } + + private Map loadLinkStatsByEntityId(List entities) { + Set entityIds = entities.stream() + .map(FileEntity::getId) + .filter(id -> id != null) + .collect(Collectors.toSet()); + if (entityIds.isEmpty()) { + return Collections.emptyMap(); + } + return storedFileEntityRepository.findAdminLinkStatsByFileEntityIds(entityIds).stream() + .collect(Collectors.toMap( + StoredFileEntityRepository.FileEntityLinkStatsProjection::getFileEntityId, + Function.identity() + )); + } + private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) { StoredFile file = shareLink.getFile(); User owner = shareLink.getOwner(); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java index a8965ed..a8da3bc 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserGovernanceService.java @@ -21,7 +21,10 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -41,8 +44,12 @@ public class AdminUserGovernanceService { normalizeQuery(query), PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) ); + List users = result.getContent(); + Map usedStorageByUserId = loadUsedStorageByUserIds(users); return new PageResponse<>( - result.getContent().stream().map(this::toUserResponse).toList(), + users.stream() + .map(user -> toUserResponse(user, usedStorageByUserId.getOrDefault(user.getId(), 0L))) + .toList(), result.getTotalElements(), page, size @@ -150,7 +157,10 @@ public class AdminUserGovernanceService { } private AdminUserResponse toUserResponse(User user) { - long usedStorageBytes = storedFileRepository.sumFileSizeByUserId(user.getId()); + return toUserResponse(user, storedFileRepository.sumFileSizeByUserId(user.getId())); + } + + private AdminUserResponse toUserResponse(User user, long usedStorageBytes) { return new AdminUserResponse( user.getId(), user.getUsername(), @@ -165,6 +175,21 @@ public class AdminUserGovernanceService { ); } + private Map loadUsedStorageByUserIds(List users) { + Set userIds = users.stream() + .map(User::getId) + .filter(id -> id != null) + .collect(Collectors.toSet()); + if (userIds.isEmpty()) { + return Map.of(); + } + return storedFileRepository.sumFileSizeByUserIds(userIds).stream() + .collect(Collectors.toMap( + StoredFileRepository.UserStorageUsageProjection::getUserId, + projection -> projection.getUsedStorageBytes() == null ? 0L : projection.getUsedStorageBytes() + )); + } + private User getRequiredUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found")); diff --git a/backend/src/main/java/com/yoyuzh/files/core/FileBlobRepository.java b/backend/src/main/java/com/yoyuzh/files/core/FileBlobRepository.java index 9ad3bd8..af2eebd 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/FileBlobRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/core/FileBlobRepository.java @@ -3,12 +3,16 @@ package com.yoyuzh.files.core; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface FileBlobRepository extends JpaRepository { Optional findByObjectKey(String objectKey); + List findAllByObjectKeyIn(Collection objectKeys); + @Query(""" select coalesce(sum(b.size), 0) from FileBlob b diff --git a/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java b/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java index 76926f4..cb032f5 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/core/StoredFileEntityRepository.java @@ -4,8 +4,23 @@ 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; + public interface StoredFileEntityRepository extends JpaRepository { + interface FileEntityLinkStatsProjection { + Long getFileEntityId(); + + Long getLinkedStoredFileCount(); + + Long getLinkedOwnerCount(); + + String getSampleOwnerUsername(); + + String getSampleOwnerEmail(); + } + @Query(""" select count(distinct relation.storedFile.id) from StoredFileEntity relation @@ -41,4 +56,18 @@ public interface StoredFileEntityRepository extends JpaRepository findAdminLinkStatsByFileEntityIds(@Param("fileEntityIds") Collection fileEntityIds); } diff --git a/backend/src/main/java/com/yoyuzh/files/core/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/core/StoredFileRepository.java index 4e4b232..bb71fe0 100644 --- a/backend/src/main/java/com/yoyuzh/files/core/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/core/StoredFileRepository.java @@ -8,11 +8,18 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; public interface StoredFileRepository extends JpaRepository { + interface UserStorageUsageProjection { + Long getUserId(); + + Long getUsedStorageBytes(); + } + @EntityGraph(attributePaths = {"user", "blob"}) Page findAllByOrderByCreatedAtDesc(Pageable pageable); @@ -104,6 +111,14 @@ public interface StoredFileRepository extends JpaRepository { """) long sumFileSizeByUserId(@Param("userId") Long userId); + @Query(""" + select f.user.id as userId, coalesce(sum(f.size), 0) as usedStorageBytes + from StoredFile f + where f.user.id in :userIds and f.directory = false and f.deletedAt is null + group by f.user.id + """) + List sumFileSizeByUserIds(@Param("userIds") Collection userIds); + @Query(""" select coalesce(sum(f.size), 0) from StoredFile f diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java index 63646c7..e9d0c11 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminInspectionQueryServiceTest.java @@ -5,7 +5,10 @@ import com.yoyuzh.auth.User; import com.yoyuzh.auth.UserRepository; import com.yoyuzh.auth.UserRole; import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.core.FileBlob; import com.yoyuzh.files.core.FileBlobRepository; +import com.yoyuzh.files.core.FileEntity; +import com.yoyuzh.files.core.FileEntityType; import com.yoyuzh.files.core.FileEntityRepository; import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFileEntityRepository; @@ -25,6 +28,9 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -123,6 +129,63 @@ class AdminInspectionQueryServiceTest { assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice"); } + @Test + void shouldListFileBlobsWithBatchLoadedBlobAndLinkStats() { + User creator = createUser(9L, "creator", "creator@example.com"); + FileEntity entity = new FileEntity(); + entity.setId(100L); + entity.setObjectKey("blobs/a"); + entity.setEntityType(FileEntityType.VERSION); + entity.setStoragePolicyId(5L); + entity.setSize(1024L); + entity.setContentType("application/pdf"); + entity.setReferenceCount(1); + entity.setCreatedBy(creator); + entity.setCreatedAt(LocalDateTime.now().minusMinutes(2)); + + FileBlob blob = new FileBlob(); + blob.setId(88L); + blob.setObjectKey("blobs/a"); + blob.setContentType("application/pdf"); + blob.setSize(1024L); + blob.setCreatedAt(LocalDateTime.now().minusMinutes(3)); + + StoredFileEntityRepository.FileEntityLinkStatsProjection linkStats = mock(StoredFileEntityRepository.FileEntityLinkStatsProjection.class); + when(linkStats.getFileEntityId()).thenReturn(100L); + when(linkStats.getLinkedStoredFileCount()).thenReturn(1L); + when(linkStats.getLinkedOwnerCount()).thenReturn(1L); + when(linkStats.getSampleOwnerUsername()).thenReturn("alice"); + when(linkStats.getSampleOwnerEmail()).thenReturn("alice@example.com"); + + when(fileEntityRepository.searchAdminEntities(anyString(), any(), anyString(), any(), any())) + .thenReturn(new PageImpl<>(List.of(entity))); + when(fileBlobRepository.findAllByObjectKeyIn(any())).thenReturn(List.of(blob)); + when(storedFileEntityRepository.findAdminLinkStatsByFileEntityIds(any())).thenReturn(List.of(linkStats)); + + PageResponse response = adminInspectionQueryService.listFileBlobs( + 0, + 10, + null, + null, + null, + null + ); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).entityId()).isEqualTo(100L); + assertThat(response.items().get(0).blobId()).isEqualTo(88L); + assertThat(response.items().get(0).linkedStoredFileCount()).isEqualTo(1L); + assertThat(response.items().get(0).linkedOwnerCount()).isEqualTo(1L); + assertThat(response.items().get(0).sampleOwnerUsername()).isEqualTo("alice"); + assertThat(response.items().get(0).sampleOwnerEmail()).isEqualTo("alice@example.com"); + + verify(fileBlobRepository, never()).findByObjectKey(anyString()); + verify(storedFileEntityRepository, never()).countByFileEntityId(any()); + verify(storedFileEntityRepository, never()).countDistinctOwnersByFileEntityId(any()); + verify(storedFileEntityRepository, never()).findSampleOwnerUsernameByFileEntityId(any()); + verify(storedFileEntityRepository, never()).findSampleOwnerEmailByFileEntityId(any()); + } + private User createUser(Long id, String username, String email) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java index 141ce8e..b33d991 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminUserGovernanceServiceTest.java @@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -67,9 +68,12 @@ class AdminUserGovernanceServiceTest { @Test void shouldListUsersWithPagination() { User user = createUser(1L, "alice", "alice@example.com"); + StoredFileRepository.UserStorageUsageProjection usageProjection = mock(StoredFileRepository.UserStorageUsageProjection.class); + when(usageProjection.getUserId()).thenReturn(1L); + when(usageProjection.getUsedStorageBytes()).thenReturn(2048L); when(userRepository.searchByUsernameOrEmail(anyString(), any())) .thenReturn(new PageImpl<>(List.of(user))); - when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(2048L); + when(storedFileRepository.sumFileSizeByUserIds(any())).thenReturn(List.of(usageProjection)); PageResponse response = adminUserGovernanceService.listUsers(0, 10, "alice"); diff --git a/front/src/App.tsx b/front/src/App.tsx index f47b9ac..b80d3bc 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,30 +1,29 @@ +import { Suspense, lazy } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'; -import { AnimatePresence, motion } from 'motion/react'; -import AdminDashboard from './admin/dashboard'; -import AdminFilesList from './admin/files-list'; -import AdminStoragePoliciesList from './admin/storage-policies-list'; -import AdminUsersList from './admin/users-list'; -import AdminLayout from './admin/AdminLayout'; - -// 新增占位页面 -import AdminSettings from './admin/settings'; -import AdminFilesystem from './admin/filesystem'; -import AdminFileBlobs from './admin/fileblobs'; -import AdminShares from './admin/shares'; -import AdminTasks from './admin/tasks'; -import AdminOAuthApps from './admin/oauthapps'; - -import Layout from './components/layout/Layout'; -import MobileLayout from './mobile-components/MobileLayout'; +import { AnimatePresence } from 'motion/react'; import { useIsMobile } from './hooks/useIsMobile'; -import Login from './account/pages/LoginPage'; -import Overview from './workspace/pages/OverviewPage'; -import FilesPage from './workspace/pages/FilesPage'; -import RecycleBin from './workspace/pages/RecycleBinPage'; -import Shares from './sharing/pages/SharesPage'; -import FileShare from './sharing/pages/FileSharePage'; -import Tasks from './common/pages/TasksPage'; -import Transfer from './transfer/pages/TransferPage'; + +const Layout = lazy(() => import('./components/layout/Layout')); +const MobileLayout = lazy(() => import('./mobile-components/MobileLayout')); +const Login = lazy(() => import('./account/pages/LoginPage')); +const Overview = lazy(() => import('./workspace/pages/OverviewPage')); +const FilesPage = lazy(() => import('./workspace/pages/FilesPage')); +const RecycleBin = lazy(() => import('./workspace/pages/RecycleBinPage')); +const Shares = lazy(() => import('./sharing/pages/SharesPage')); +const FileShare = lazy(() => import('./sharing/pages/FileSharePage')); +const Tasks = lazy(() => import('./common/pages/TasksPage')); +const Transfer = lazy(() => import('./transfer/pages/TransferPage')); +const AdminLayout = lazy(() => import('./admin/AdminLayout')); +const AdminDashboard = lazy(() => import('./admin/dashboard')); +const AdminSettings = lazy(() => import('./admin/settings')); +const AdminFilesystem = lazy(() => import('./admin/filesystem')); +const AdminStoragePoliciesList = lazy(() => import('./admin/storage-policies-list')); +const AdminUsersList = lazy(() => import('./admin/users-list')); +const AdminFilesList = lazy(() => import('./admin/files-list')); +const AdminFileBlobs = lazy(() => import('./admin/fileblobs')); +const AdminShares = lazy(() => import('./admin/shares')); +const AdminTasks = lazy(() => import('./admin/tasks')); +const AdminOAuthApps = lazy(() => import('./admin/oauthapps')); function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { const location = useLocation(); @@ -32,40 +31,51 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) { return ( - - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* 管理台路由重构 */} - : }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - - + : }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ); } +function RouteLoadingFallback() { + return ( +
+
+ Loading... +
+
+ ); +} + export default function App() { const isMobile = useIsMobile(); diff --git a/memory.md b/memory.md index 52c585e..3ef4f2f 100644 --- a/memory.md +++ b/memory.md @@ -821,3 +821,27 @@ - Verification passed with: - `cd front && npm run lint` - `cd front && npm run build` + +## 2026-04-12 Backend/Frontend Refactor Batch 26 + +- Review follow-up perf fixes were applied for two admin read-path N+1 hotspots plus route-level code-splitting in frontend root router. +- `AdminInspectionQueryService#listFileBlobs(...)` no longer executes per-row blob/owner/stat lookups: +- introduced batch blob load by object-key (`FileBlobRepository.findAllByObjectKeyIn(...)`) +- introduced batch entity-link aggregate query (`StoredFileEntityRepository.findAdminLinkStatsByFileEntityIds(...)`) +- mapping now uses in-memory maps (`objectKey -> blob`, `entityId -> link stats`) and keeps existing response semantics (`blobMissing`, `orphanRisk`, `referenceMismatch`) unchanged. +- `AdminUserGovernanceService#listUsers(...)` no longer executes per-user storage sum: +- introduced `StoredFileRepository.sumFileSizeByUserIds(...)` grouped aggregate query +- list path now batch-loads storage usage map and maps each row without per-user SQL. +- Regression tests updated/added: +- `AdminInspectionQueryServiceTest` now covers batch blob/link-stat mapping path and asserts old per-row repository methods are no longer used. +- `AdminUserGovernanceServiceTest` list-users path now stubs/asserts grouped `sumFileSizeByUserIds(...)`. +- Frontend route loading in `front/src/App.tsx` switched to lazy imports + `Suspense` fallback: +- all main pages and admin pages/layouts are now route-level lazy chunks instead of root synchronous imports. +- Verification passed with: +- `cd backend && mvn "-Dtest=AdminInspectionQueryServiceTest,AdminUserGovernanceServiceTest,AdminControllerIntegrationTest" test` +- result: 34 tests run, 0 failures +- full regression `cd backend && mvn test` +- Backend total after this batch: 378 tests passed. +- `cd front && npm run lint` +- `cd front && npm run build` +- build output now shows split chunks with main entry chunk `assets/index-CXR4rSrf.js` at **244.85 kB** (previously reported ~538.17 kB), and no Vite chunk-size warning.