feat: add S3 storage support, policy migration, and quota enforcement
Some checks failed
Test / test (push) Failing after 2m21s

- Add S3StorageService with AWS Signature V4 signing (URI-encoded for non-ASCII keys)
- Add PATCH /object/{id}/policy endpoint for switching storage policies with background migration
- Implement cross-storage file migration service (local <-> S3)
- Replace deprecated StorageType enum with PolicyType (local/s3)
- Implement GET /user/settings/policies endpoint (was 501 stub)
- Add storage quota pre-allocation on upload session creation to prevent concurrent bypass
- Fix BigInteger for max_storage and user.storage to support >2GB values
- Add policy permission validation on upload and directory creation
- Use group's first policy as default on registration instead of hardcoded name
- Define TaskType.POLICY_MIGRATE and extend TaskProps with migration fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 13:38:20 +08:00
parent 7200df6d87
commit 3639a31163
19 changed files with 1728 additions and 124 deletions

View File

@@ -954,18 +954,11 @@ class PolicyType(StrEnum):
S3 = "s3" # S3 兼容存储
```
### StorageType
### PolicyType
```python
class StorageType(StrEnum):
class PolicyType(StrEnum):
LOCAL = "local" # 本地存储
QINIU = "qiniu" # 七牛云
TENCENT = "tencent" # 腾讯云
ALIYUN = "aliyun" # 阿里云
ONEDRIVE = "onedrive" # OneDrive
GOOGLE_DRIVE = "google_drive" # Google Drive
DROPBOX = "dropbox" # Dropbox
WEBDAV = "webdav" # WebDAV
REMOTE = "remote" # 远程存储
S3 = "s3" # S3 兼容存储
```
### UserStatus

View File

@@ -82,6 +82,7 @@ from .object import (
ObjectPropertyResponse,
ObjectRenameRequest,
ObjectResponse,
ObjectSwitchPolicyRequest,
ObjectType,
PolicyResponse,
UploadChunkResponse,
@@ -100,7 +101,10 @@ from .object import (
from .physical_file import PhysicalFile, PhysicalFileBase
from .uri import DiskNextURI, FileSystemNamespace
from .order import Order, OrderStatus, OrderType
from .policy import Policy, PolicyBase, PolicyOptions, PolicyOptionsBase, PolicyType, PolicySummary
from .policy import (
Policy, PolicyBase, PolicyCreateRequest, PolicyOptions, PolicyOptionsBase,
PolicyType, PolicySummary, PolicyUpdateRequest,
)
from .redeem import Redeem, RedeemType
from .report import Report, ReportReason
from .setting import (
@@ -116,7 +120,7 @@ from .share import (
from .source_link import SourceLink
from .storage_pack import StoragePack
from .tag import Tag, TagType
from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary
from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary, TaskSummaryBase
from .webdav import (
WebDAV, WebDAVBase,
WebDAVCreateRequest, WebDAVUpdateRequest, WebDAVAccountResponse,

View File

@@ -2,6 +2,7 @@
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship, text
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin
@@ -260,7 +261,7 @@ class Group(GroupBase, UUIDTableBaseMixin):
name: str = Field(max_length=255, unique=True)
"""用户组名"""
max_storage: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
max_storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"})
"""最大存储空间(字节)"""
share_enabled: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")})

View File

@@ -9,6 +9,8 @@ from sqlmodel import Field, Relationship, CheckConstraint, Index, text
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin
from .policy import PolicyType
if TYPE_CHECKING:
from .user import User
from .policy import Policy
@@ -23,18 +25,6 @@ class ObjectType(StrEnum):
FILE = "file"
FOLDER = "folder"
class StorageType(StrEnum):
"""存储类型枚举"""
LOCAL = "local"
QINIU = "qiniu"
TENCENT = "tencent"
ALIYUN = "aliyun"
ONEDRIVE = "onedrive"
GOOGLE_DRIVE = "google_drive"
DROPBOX = "dropbox"
WEBDAV = "webdav"
REMOTE = "remote"
class FileMetadataBase(SQLModelBase):
"""文件元数据基础模型"""
@@ -156,7 +146,7 @@ class PolicyResponse(SQLModelBase):
name: str
"""策略名称"""
type: StorageType
type: PolicyType
"""存储类型"""
max_size: int = Field(ge=0, default=0, sa_type=BigInteger)
@@ -624,6 +614,12 @@ class UploadSession(UploadSessionBase, UUIDTableBaseMixin):
storage_path: str | None = Field(default=None, max_length=512)
"""文件存储路径"""
s3_upload_id: str | None = Field(default=None, max_length=256)
"""S3 Multipart Upload ID仅 S3 策略使用)"""
s3_part_etags: str | None = None
"""S3 已上传分片的 ETag 列表JSON 格式 [[1,"etag1"],[2,"etag2"]](仅 S3 策略使用)"""
expires_at: datetime
"""会话过期时间"""
@@ -732,6 +728,16 @@ class CreateFileRequest(SQLModelBase):
"""存储策略UUID不指定则使用父目录的策略"""
class ObjectSwitchPolicyRequest(SQLModelBase):
"""切换对象存储策略请求"""
policy_id: UUID
"""目标存储策略UUID"""
is_migrate_existing: bool = False
"""(仅目录)是否迁移已有文件,默认 false 只影响新文件"""
# ==================== 对象操作相关 DTO ====================
class ObjectCopyRequest(SQLModelBase):

View File

@@ -102,6 +102,94 @@ class PolicySummary(SQLModelBase):
"""是否私有"""
class PolicyCreateRequest(PolicyBase):
"""创建存储策略请求 DTO包含 PolicyOptions 扁平字段"""
# PolicyOptions 字段(平铺到请求体中,与 GroupCreateRequest 模式一致)
token: str | None = None
"""访问令牌"""
file_type: str | None = None
"""允许的文件类型"""
mimetype: str | None = Field(default=None, max_length=127)
"""MIME类型"""
od_redirect: str | None = Field(default=None, max_length=255)
"""OneDrive重定向地址"""
chunk_size: int = Field(default=52428800, ge=1)
"""分片上传大小字节默认50MB"""
s3_path_style: bool = False
"""是否使用S3路径风格"""
s3_region: str = Field(default='us-east-1', max_length=64)
"""S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用"""
class PolicyUpdateRequest(SQLModelBase):
"""更新存储策略请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=255)
"""策略名称"""
server: str | None = Field(default=None, max_length=255)
"""服务器地址"""
bucket_name: str | None = Field(default=None, max_length=255)
"""存储桶名称"""
is_private: bool | None = None
"""是否为私有空间"""
base_url: str | None = Field(default=None, max_length=255)
"""访问文件的基础URL"""
access_key: str | None = None
"""Access Key"""
secret_key: str | None = None
"""Secret Key"""
max_size: int | None = Field(default=None, ge=0)
"""允许上传的最大文件尺寸(字节)"""
auto_rename: bool | None = None
"""是否自动重命名"""
dir_name_rule: str | None = Field(default=None, max_length=255)
"""目录命名规则"""
file_name_rule: str | None = Field(default=None, max_length=255)
"""文件命名规则"""
is_origin_link_enable: bool | None = None
"""是否开启源链接访问"""
# PolicyOptions 字段
token: str | None = None
"""访问令牌"""
file_type: str | None = None
"""允许的文件类型"""
mimetype: str | None = Field(default=None, max_length=127)
"""MIME类型"""
od_redirect: str | None = Field(default=None, max_length=255)
"""OneDrive重定向地址"""
chunk_size: int | None = Field(default=None, ge=1)
"""分片上传大小(字节)"""
s3_path_style: bool | None = None
"""是否使用S3路径风格"""
s3_region: str | None = Field(default=None, max_length=64)
"""S3 区域"""
# ==================== 数据库模型 ====================
@@ -126,6 +214,9 @@ class PolicyOptionsBase(SQLModelBase):
s3_path_style: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")})
"""是否使用S3路径风格"""
s3_region: str = Field(default='us-east-1', max_length=64, sa_column_kwargs={"server_default": "'us-east-1'"})
"""S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用"""
class PolicyOptions(PolicyOptionsBase, UUIDTableBaseMixin):
"""存储策略选项模型与Policy一对一关联"""

View File

@@ -26,8 +26,8 @@ class TaskStatus(StrEnum):
class TaskType(StrEnum):
"""任务类型枚举"""
# [TODO] 补充具体任务类型
pass
POLICY_MIGRATE = "policy_migrate"
"""存储策略迁移"""
# ==================== DTO 模型 ====================
@@ -39,7 +39,7 @@ class TaskSummaryBase(SQLModelBase):
id: int
"""任务ID"""
type: int
type: TaskType
"""任务类型"""
status: TaskStatus
@@ -91,7 +91,14 @@ class TaskPropsBase(SQLModelBase):
file_ids: str | None = None
"""文件ID列表逗号分隔"""
# [TODO] 根据业务需求补充更多字段
source_policy_id: UUID | None = None
"""源存储策略UUID"""
dest_policy_id: UUID | None = None
"""目标存储策略UUID"""
object_id: UUID | None = None
"""关联的对象UUID"""
class TaskProps(TaskPropsBase, TableBaseMixin):
@@ -99,7 +106,7 @@ class TaskProps(TaskPropsBase, TableBaseMixin):
task_id: int = Field(
foreign_key="task.id",
primary_key=True,
unique=True,
ondelete="CASCADE"
)
"""关联的任务ID"""
@@ -121,8 +128,8 @@ class Task(SQLModelBase, TableBaseMixin):
status: TaskStatus = Field(default=TaskStatus.QUEUED)
"""任务状态"""
type: int = Field(default=0)
"""任务类型 [TODO] 待定义枚举"""
type: TaskType
"""任务类型"""
progress: int = Field(default=0, ge=0, le=100)
"""任务进度0-100"""

View File

@@ -4,7 +4,7 @@ from typing import Literal, TYPE_CHECKING, TypeVar
from uuid import UUID
from pydantic import BaseModel
from sqlalchemy import BinaryExpression, ClauseElement, and_
from sqlalchemy import BigInteger, BinaryExpression, ClauseElement, and_
from sqlmodel import Field, Relationship
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import RelationshipInfo
@@ -473,7 +473,7 @@ class User(UserBase, UUIDTableBaseMixin):
status: UserStatus = UserStatus.ACTIVE
"""用户状态"""
storage: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"}, ge=0)
"""已用存储空间(字节)"""
avatar: str = Field(default="default", max_length=255)