feat: add S3 storage support, policy migration, and quota enforcement
Some checks failed
Test / test (push) Failing after 2m21s
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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一对一关联)"""
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user