feat(admin): show storage policies

This commit is contained in:
yoyuzh
2026-04-08 21:54:22 +08:00
parent 3e67760712
commit c5362ebe31
14 changed files with 282 additions and 3 deletions

View File

@@ -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<List<AdminStoragePolicyResponse>> storagePolicies() {
return ApiResponse.success(adminService.listStoragePolicies());
}
@DeleteMapping("/files/{fileId}")
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
adminService.deleteFile(fileId);

View File

@@ -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<AdminStoragePolicyResponse> 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, "用户不存在"));

View File

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

View File

@@ -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("没有权限访问该资源"));
}
}

View File

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

View File

@@ -380,6 +380,17 @@
- `GET /api/admin/files`
- `DELETE /api/admin/files/{fileId}`
### 5.4 存储策略
`GET /api/admin/storage-policies`
说明:
- 需要管理员登录
- 返回当前存储策略的只读列表和结构化能力声明
- 当前仅用于管理台查看默认策略、启用状态、存储类型和能力矩阵,不支持新增、编辑、启停或删除策略
- `capabilities.multipartUpload` 当前仍为能力声明字段,不代表真实对象存储 multipart 已启用
## 6. 前端公开路由与接口关系
前端入口在:

View File

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

View File

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

View File

@@ -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"
/>
<Resource
name="storagePolicies"
icon={StorageRounded}
list={PortalAdminStoragePoliciesList}
options={{ label: '存储策略' }}
recordRepresentation="name"
/>
</Admin>
);
}

View File

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

View File

@@ -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<GetListParams, 'pagination' | 'f
return `/admin/files?${search.toString()}`;
}
export function buildStoragePoliciesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
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<AdminFile>,
): GetListResult<AdminFile> {
@@ -86,6 +94,14 @@ export const portalAdminDataProvider: DataProvider = {
} as GetListResult;
}
if (resource === STORAGE_POLICIES_RESOURCE) {
const payload = await apiRequest<AdminStoragePolicy[]>(buildStoragePoliciesListPath(params));
return {
data: payload,
total: payload.length,
} as GetListResult;
}
throw createUnsupportedError(resource, 'list');
},
getOne: async (resource) => {

View File

@@ -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 (
<TopToolbar>
<RefreshButton />
</TopToolbar>
);
}
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 (
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{CAPABILITY_LABELS.map(({ key, label }) => {
const enabled = capabilities[key] === true;
return (
<Chip
key={key}
color={enabled ? 'success' : 'default'}
label={`${label}${enabled ? '开' : '关'}`}
size="small"
variant={enabled ? 'filled' : 'outlined'}
/>
);
})}
</Stack>
);
}
export function PortalAdminStoragePoliciesList() {
return (
<List
actions={<StoragePoliciesListActions />}
perPage={25}
resource="storagePolicies"
title="存储策略"
sort={{ field: 'id', order: 'ASC' }}
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<TextField source="id" label="ID" />
<TextField source="name" label="名称" />
<TextField source="type" label="类型" />
<TextField source="bucketName" label="Bucket" emptyText="-" />
<TextField source="endpoint" label="Endpoint" emptyText="-" />
<TextField source="region" label="Region" emptyText="-" />
<TextField source="prefix" label="Prefix" emptyText="-" />
<TextField source="credentialMode" label="凭证模式" />
<BooleanField source="enabled" label="启用" />
<BooleanField source="defaultPolicy" label="默认" />
<FunctionField<AdminStoragePolicy>
label="容量上限"
render={(record) => formatFileSize(record.maxSizeBytes)}
/>
<FunctionField<AdminStoragePolicy>
label="能力"
render={(record) => renderCapabilities(record.capabilities)}
/>
<DateField source="updatedAt" label="更新时间" showTime />
</Datagrid>
</List>
);
}

View File

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

View File

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