feat(admin): show storage policies
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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, "用户不存在"));
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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("没有权限访问该资源"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -380,6 +380,17 @@
|
||||
- `GET /api/admin/files`
|
||||
- `DELETE /api/admin/files/{fileId}`
|
||||
|
||||
### 5.4 存储策略
|
||||
|
||||
`GET /api/admin/storage-policies`
|
||||
|
||||
说明:
|
||||
|
||||
- 需要管理员登录
|
||||
- 返回当前存储策略的只读列表和结构化能力声明
|
||||
- 当前仅用于管理台查看默认策略、启用状态、存储类型和能力矩阵,不支持新增、编辑、启停或删除策略
|
||||
- `capabilities.multipartUpload` 当前仍为能力声明字段,不代表真实对象存储 multipart 已启用
|
||||
|
||||
## 6. 前端公开路由与接口关系
|
||||
|
||||
前端入口在:
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
98
front/src/admin/storage-policies-list.tsx
Normal file
98
front/src/admin/storage-policies-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user