From c5362ebe3140ce3ac681372ca4a604e340bf3df9 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Wed, 8 Apr 2026 21:54:22 +0800 Subject: [PATCH] feat(admin): show storage policies --- .../com/yoyuzh/admin/AdminController.java | 7 ++ .../java/com/yoyuzh/admin/AdminService.java | 34 +++++++ .../admin/AdminStoragePolicyResponse.java | 26 +++++ .../admin/AdminControllerIntegrationTest.java | 22 +++++ .../com/yoyuzh/admin/AdminServiceTest.java | 9 +- docs/api-reference.md | 11 +++ docs/architecture.md | 1 + ...cloudreve-inspired-upgrade-and-refactor.md | 2 +- front/src/admin/AdminApp.tsx | 9 ++ front/src/admin/data-provider.test.ts | 14 +++ front/src/admin/data-provider.ts | 18 +++- front/src/admin/storage-policies-list.tsx | 98 +++++++++++++++++++ front/src/lib/types.ts | 33 +++++++ memory.md | 1 + 14 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponse.java create mode 100644 front/src/admin/storage-policies-list.tsx diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java index 4092700..7ca8b62 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminController.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -16,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequestMapping("/api/admin") @RequiredArgsConstructor @@ -52,6 +54,11 @@ public class AdminController { return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery)); } + @GetMapping("/storage-policies") + public ApiResponse> storagePolicies() { + return ApiResponse.success(adminService.listStoragePolicies()); + } + @DeleteMapping("/files/{fileId}") public ApiResponse deleteFile(@PathVariable Long fileId) { adminService.deleteFile(fileId); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index a3f5d00..f185066 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -13,6 +13,9 @@ import com.yoyuzh.files.FileBlobRepository; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; +import com.yoyuzh.files.StoragePolicy; +import com.yoyuzh.files.StoragePolicyRepository; +import com.yoyuzh.files.StoragePolicyService; import com.yoyuzh.transfer.OfflineTransferSessionRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -40,6 +43,8 @@ public class AdminService { private final RegistrationInviteService registrationInviteService; private final OfflineTransferSessionRepository offlineTransferSessionRepository; private final AdminMetricsService adminMetricsService; + private final StoragePolicyRepository storagePolicyRepository; + private final StoragePolicyService storagePolicyService; private final SecureRandom secureRandom = new SecureRandom(); public AdminSummaryResponse getSummary() { @@ -83,6 +88,15 @@ public class AdminService { return new PageResponse<>(items, result.getTotalElements(), page, size); } + public List listStoragePolicies() { + return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy") + .and(Sort.by(Sort.Direction.DESC, "enabled")) + .and(Sort.by(Sort.Direction.ASC, "id"))) + .stream() + .map(this::toStoragePolicyResponse) + .toList(); + } + @Transactional public void deleteFile(Long fileId) { StoredFile storedFile = storedFileRepository.findById(fileId) @@ -180,6 +194,26 @@ public class AdminService { ); } + private AdminStoragePolicyResponse toStoragePolicyResponse(StoragePolicy policy) { + return new AdminStoragePolicyResponse( + policy.getId(), + policy.getName(), + policy.getType(), + policy.getBucketName(), + policy.getEndpoint(), + policy.getRegion(), + policy.isPrivateBucket(), + policy.getPrefix(), + policy.getCredentialMode(), + policy.getMaxSizeBytes(), + storagePolicyService.readCapabilities(policy), + policy.isEnabled(), + policy.isDefaultPolicy(), + policy.getCreatedAt(), + policy.getUpdatedAt() + ); + } + private User getRequiredUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在")); diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponse.java new file mode 100644 index 0000000..3e042c2 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminStoragePolicyResponse.java @@ -0,0 +1,26 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.files.StoragePolicyCapabilities; +import com.yoyuzh.files.StoragePolicyCredentialMode; +import com.yoyuzh.files.StoragePolicyType; + +import java.time.LocalDateTime; + +public record AdminStoragePolicyResponse( + Long id, + String name, + StoragePolicyType type, + String bucketName, + String endpoint, + String region, + boolean privateBucket, + String prefix, + StoragePolicyCredentialMode credentialMode, + long maxSizeBytes, + StoragePolicyCapabilities capabilities, + boolean enabled, + boolean defaultPolicy, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index e6640d8..3d3ea3e 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -320,11 +320,33 @@ class AdminControllerIntegrationTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + @WithMockUser(username = "admin") + void shouldAllowConfiguredAdminToListStoragePolicies() throws Exception { + mockMvc.perform(get("/api/admin/storage-policies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.length()", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.data[0].name").value("Default Local Storage")) + .andExpect(jsonPath("$.data[0].type").value("LOCAL")) + .andExpect(jsonPath("$.data[0].enabled").value(true)) + .andExpect(jsonPath("$.data[0].defaultPolicy").value(true)) + .andExpect(jsonPath("$.data[0].capabilities.directUpload").value(false)) + .andExpect(jsonPath("$.data[0].capabilities.multipartUpload").value(false)) + .andExpect(jsonPath("$.data[0].capabilities.serverProxyDownload").value(true)) + .andExpect(jsonPath("$.data[0].capabilities.requiresCors").value(false)) + .andExpect(jsonPath("$.data[0].maxSizeBytes").isNumber()); + } + @Test @WithMockUser(username = "portal-user") void shouldRejectNonAdminUser() throws Exception { mockMvc.perform(get("/api/admin/users?page=0&size=10")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.msg").value("没有权限访问该资源")); + + mockMvc.perform(get("/api/admin/storage-policies")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.msg").value("没有权限访问该资源")); } } diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java index 7bafcbd..5aaa352 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java @@ -12,6 +12,8 @@ import com.yoyuzh.files.FileBlobRepository; import com.yoyuzh.files.FileService; import com.yoyuzh.files.StoredFile; import com.yoyuzh.files.StoredFileRepository; +import com.yoyuzh.files.StoragePolicyRepository; +import com.yoyuzh.files.StoragePolicyService; import com.yoyuzh.transfer.OfflineTransferSessionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,6 +58,10 @@ class AdminServiceTest { private OfflineTransferSessionRepository offlineTransferSessionRepository; @Mock private AdminMetricsService adminMetricsService; + @Mock + private StoragePolicyRepository storagePolicyRepository; + @Mock + private StoragePolicyService storagePolicyService; private AdminService adminService; @@ -64,7 +70,8 @@ class AdminServiceTest { adminService = new AdminService( userRepository, storedFileRepository, fileBlobRepository, fileService, passwordEncoder, refreshTokenService, registrationInviteService, - offlineTransferSessionRepository, adminMetricsService); + offlineTransferSessionRepository, adminMetricsService, + storagePolicyRepository, storagePolicyService); } // --- getSummary --- diff --git a/docs/api-reference.md b/docs/api-reference.md index 6867c38..c3f746d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -380,6 +380,17 @@ - `GET /api/admin/files` - `DELETE /api/admin/files/{fileId}` +### 5.4 存储策略 + +`GET /api/admin/storage-policies` + +说明: + +- 需要管理员登录 +- 返回当前存储策略的只读列表和结构化能力声明 +- 当前仅用于管理台查看默认策略、启用状态、存储类型和能力矩阵,不支持新增、编辑、启停或删除策略 +- `capabilities.multipartUpload` 当前仍为能力声明字段,不代表真实对象存储 multipart 已启用 + ## 6. 前端公开路由与接口关系 前端入口在: diff --git a/docs/architecture.md b/docs/architecture.md index 7f8dd8a..a2e9075 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -452,3 +452,4 @@ Android 壳补充说明: - 2026-04-08 阶段 4 第一小步补充:后端新增存储策略骨架。`StoragePolicyService` 作为 `CommandLineRunner` 在启动时确保存在默认策略,并把当前 `FileStorageProperties` 映射为 `LOCAL` 或 `S3_COMPATIBLE` 策略及 `StoragePolicyCapabilities` JSON;当前能力声明中 `multipartUpload=false`,用于明确真实对象存储分片写入/合并还没有启用。`UploadSession.storagePolicyId` 开始记录默认策略 ID,但 `FileContentStorage` 仍保持单对象上传/校验抽象,旧 `/api/files/**` 生产路径不切换。 - 2026-04-08 `files/storage` 合并补充:S3 存储实现拆出多吉云临时密钥客户端与运行期会话提供器。`S3FileContentStorage` 现在通过 `S3SessionProvider.currentSession()` 获取当前 bucket、`S3Client` 和 `S3Presigner`,避免每次操作重复内联多吉云 token 解析逻辑;测试环境可直接注入 mock S3 client/presigner。该改动没有引入 multipart,仍是单对象 PUT/HEAD/GET/COPY/DELETE 路径。 - 2026-04-08 阶段 4 第二小步补充:`FileService` 在创建新的 `FileEntity.VERSION` 时会通过 `StoragePolicyService.ensureDefaultPolicy()` 写入默认 `storagePolicyId`;`FileEntityBackfillService` 对历史 `FileBlob` 回填新实体时也写入同一默认策略。复用已有实体时保持原策略字段不变,只增加引用计数,避免在兼容迁移阶段覆盖历史数据。 +- 2026-04-08 阶段 4 第三小步补充:管理台新增只读存储策略列表。`AdminController` 暴露 `GET /api/admin/storage-policies`,`AdminService` 通过白名单 DTO 返回策略基础字段和结构化 `StoragePolicyCapabilities`;前端 `react-admin` 新增 `storagePolicies` 资源展示能力矩阵。该能力只做配置可视化,不改变旧上传下载路径,不暴露凭证,不启用策略编辑或 multipart。 diff --git a/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md b/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md index 9f30c34..d4ba544 100644 --- a/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md +++ b/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md @@ -480,7 +480,7 @@ WebDAV 是很有价值但不应太早做的工程。必须等文件模型、权 ### 阶段 4:存储策略管理 - 默认策略迁移。 -- 管理后台只读列表。 +- 管理后台只读列表和能力矩阵:已通过 `/api/admin/storage-policies` 与前端 `storagePolicies` 管理台资源落地。 - 文件写入策略 ID。 - 能力矩阵展示。 - 2026-04-08 追加评估:不建议在阶段 3 末尾直接把 v2 上传会话接入真实对象存储 multipart。当前仓库的 `FileContentStorage` 只有单对象 PUT/校验/删除抽象,`UploadSession` 还没有 `multipartUploadId` 和 abort 语义;S3 multipart 未完成分片需要显式 abort,不能只靠 `deleteBlob(objectKey)` 清理。先完成本阶段的存储策略与能力声明,把 `multipartUpload`、`directUpload`、`signedDownloadUrl`、`requiresCors` 等能力落库,再按启用策略实现 S3 multipart。 diff --git a/front/src/admin/AdminApp.tsx b/front/src/admin/AdminApp.tsx index 0c1d3ea..335d67c 100644 --- a/front/src/admin/AdminApp.tsx +++ b/front/src/admin/AdminApp.tsx @@ -1,11 +1,13 @@ import FolderOutlined from '@mui/icons-material/FolderOutlined'; import GroupsOutlined from '@mui/icons-material/GroupsOutlined'; +import StorageRounded from '@mui/icons-material/StorageRounded'; import { Admin, Resource } from 'react-admin'; import { portalAdminAuthProvider } from './auth-provider'; import { portalAdminDataProvider } from './data-provider'; import { PortalAdminDashboard } from './dashboard'; import { PortalAdminFilesList } from './files-list'; +import { PortalAdminStoragePoliciesList } from './storage-policies-list'; import { PortalAdminUsersList } from './users-list'; export default function PortalAdminApp() { @@ -33,6 +35,13 @@ export default function PortalAdminApp() { options={{ label: '文件资源' }} recordRepresentation="filename" /> + ); } diff --git a/front/src/admin/data-provider.test.ts b/front/src/admin/data-provider.test.ts index a2ca1c8..d80326c 100644 --- a/front/src/admin/data-provider.test.ts +++ b/front/src/admin/data-provider.test.ts @@ -6,6 +6,7 @@ import type { AdminFile, PageResponse } from '@/src/lib/types'; import { buildAdminListPath, buildFilesListPath, + buildStoragePoliciesListPath, mapFilesListResponse, } from './data-provider'; @@ -106,3 +107,16 @@ test('buildAdminListPath rejects the removed school snapshots resource', () => { /schoolSnapshots/, ); }); + +test('buildStoragePoliciesListPath maps react-admin pagination to the backend storage policies list query', () => { + assert.equal( + buildStoragePoliciesListPath({ + pagination: { + page: 2, + perPage: 10, + }, + filter: {}, + }), + '/admin/storage-policies?page=1&size=10', + ); +}); diff --git a/front/src/admin/data-provider.ts b/front/src/admin/data-provider.ts index b345681..cbeffe9 100644 --- a/front/src/admin/data-provider.ts +++ b/front/src/admin/data-provider.ts @@ -3,11 +3,13 @@ import type { DataProvider, GetListParams, GetListResult, Identifier } from 'rea import { apiRequest } from '@/src/lib/api'; import type { AdminFile, + AdminStoragePolicy, AdminUser, PageResponse, } from '@/src/lib/types'; const FILES_RESOURCE = 'files'; +const STORAGE_POLICIES_RESOURCE = 'storagePolicies'; const USERS_RESOURCE = 'users'; function createUnsupportedError(resource: string, action: string) { @@ -15,7 +17,7 @@ function createUnsupportedError(resource: string, action: string) { } function ensureSupportedResource(resource: string, action: string) { - if (![FILES_RESOURCE, USERS_RESOURCE].includes(resource)) { + if (![FILES_RESOURCE, STORAGE_POLICIES_RESOURCE, USERS_RESOURCE].includes(resource)) { throw createUnsupportedError(resource, action); } } @@ -54,6 +56,12 @@ export function buildFilesListPath(params: Pick) { + const page = Math.max(0, params.pagination.page - 1); + const size = Math.max(1, params.pagination.perPage); + return `/admin/storage-policies?page=${page}&size=${size}`; +} + export function mapFilesListResponse( payload: PageResponse, ): GetListResult { @@ -86,6 +94,14 @@ export const portalAdminDataProvider: DataProvider = { } as GetListResult; } + if (resource === STORAGE_POLICIES_RESOURCE) { + const payload = await apiRequest(buildStoragePoliciesListPath(params)); + return { + data: payload, + total: payload.length, + } as GetListResult; + } + throw createUnsupportedError(resource, 'list'); }, getOne: async (resource) => { diff --git a/front/src/admin/storage-policies-list.tsx b/front/src/admin/storage-policies-list.tsx new file mode 100644 index 0000000..d4f82c2 --- /dev/null +++ b/front/src/admin/storage-policies-list.tsx @@ -0,0 +1,98 @@ +import { Chip, Stack } from '@mui/material'; +import { + BooleanField, + Datagrid, + DateField, + FunctionField, + List, + RefreshButton, + TextField, + TopToolbar, +} from 'react-admin'; + +import type { AdminStoragePolicy, StoragePolicyCapabilities } from '@/src/lib/types'; + +const CAPABILITY_LABELS: Array<{ key: keyof StoragePolicyCapabilities; label: string }> = [ + { key: 'directUpload', label: '直传' }, + { key: 'multipartUpload', label: '分片' }, + { key: 'signedDownloadUrl', label: '签名下载' }, + { key: 'serverProxyDownload', label: '服务端下载' }, + { key: 'thumbnailNative', label: '原生缩略图' }, + { key: 'friendlyDownloadName', label: '友好文件名' }, + { key: 'requiresCors', label: 'CORS' }, + { key: 'supportsInternalEndpoint', label: '内网 endpoint' }, +]; + +function StoragePoliciesListActions() { + return ( + + + + ); +} + +function formatFileSize(size: number) { + if (size >= 1024 * 1024 * 1024) { + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } + if (size >= 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${size} B`; +} + +function renderCapabilities(capabilities: StoragePolicyCapabilities) { + return ( + + {CAPABILITY_LABELS.map(({ key, label }) => { + const enabled = capabilities[key] === true; + return ( + + ); + })} + + ); +} + +export function PortalAdminStoragePoliciesList() { + return ( + } + perPage={25} + resource="storagePolicies" + title="存储策略" + sort={{ field: 'id', order: 'ASC' }} + > + + + + + + + + + + + + + label="容量上限" + render={(record) => formatFileSize(record.maxSizeBytes)} + /> + + label="能力" + render={(record) => renderCapabilities(record.capabilities)} + /> + + + + ); +} diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index 269a667..fce1894 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -72,6 +72,39 @@ export interface AdminFile { ownerEmail: string; } +export type StoragePolicyType = 'LOCAL' | 'S3_COMPATIBLE'; +export type StoragePolicyCredentialMode = 'NONE' | 'STATIC' | 'DOGECLOUD_TEMP'; + +export interface StoragePolicyCapabilities { + directUpload: boolean; + multipartUpload: boolean; + signedDownloadUrl: boolean; + serverProxyDownload: boolean; + thumbnailNative: boolean; + friendlyDownloadName: boolean; + requiresCors: boolean; + supportsInternalEndpoint: boolean; + maxObjectSize: number; +} + +export interface AdminStoragePolicy { + id: number; + name: string; + type: StoragePolicyType; + bucketName: string | null; + endpoint: string | null; + region: string | null; + privateBucket: boolean; + prefix: string | null; + credentialMode: StoragePolicyCredentialMode; + maxSizeBytes: number; + capabilities: StoragePolicyCapabilities; + enabled: boolean; + defaultPolicy: boolean; + createdAt: string; + updatedAt: string; +} + export interface AdminPasswordResetResponse { temporaryPassword: string; } diff --git a/memory.md b/memory.md index bc1b094..42cc4ab 100644 --- a/memory.md +++ b/memory.md @@ -167,3 +167,4 @@ - 2026-04-08 阶段 4 第一小步:新增 `StoragePolicy`、`StoragePolicyType`、`StoragePolicyCredentialMode`、`StoragePolicyCapabilities` 与 `StoragePolicyService`,启动时把当前 `app.storage.provider` 映射成一条默认策略;本地策略声明 `serverProxyDownload=true`、`multipartUpload=false`,多吉云/S3 兼容策略声明 `directUpload=true`、`signedDownloadUrl=true`、`requiresCors=true`、`multipartUpload=false`。新 v2 上传会话会记录默认 `storagePolicyId`,但旧上传下载路径和前端上传队列仍未切换。 - 2026-04-08 合并 `files/storage` 补提交后修复:`S3FileContentStorage` 改为复用 `DogeCloudS3SessionProvider` / `DogeCloudTmpTokenClient` 获取并缓存运行期 `S3Client` 与 `S3Presigner`,保留生产构造器 `S3FileContentStorage(FileStorageProperties)`,同时提供测试用注入构造器;S3 直传、签名下载、上传校验、读旧对象键 fallback、rename/move/copy、离线快传对象读写继续通过 `FileContentStorage` 统一抽象。 - 2026-04-08 阶段 4 第二小步:新写入和回填生成的 `FileEntity.VERSION` 会记录默认 `StoragePolicy.id` 到 `storagePolicyId`,让物理实体可以追踪归属存储策略;复用已有 `FileEntity` 时只增加引用计数,不覆盖历史实体策略字段。旧 `/api/files/**` 读取路径仍继续依赖 `StoredFile.blob`。 +- 2026-04-08 阶段 4 第三小步:新增管理员只读存储策略查看能力,后端暴露 `GET /api/admin/storage-policies`,前端管理台新增“存储策略”资源列表和能力矩阵展示;该接口只返回白名单 DTO 与结构化 `StoragePolicyCapabilities`,不暴露凭证、不支持新增/编辑/启停/删除策略,也不启用真实 multipart。