feat: redesign metadata as KV store, add custom properties and WOPI Discovery
Some checks failed
Test / test (push) Failing after 2m32s
Some checks failed
Test / test (push) Failing after 2m32s
Replace one-to-one FileMetadata table with flexible ObjectMetadata KV pairs, add custom property definitions, WOPI Discovery auto-configuration, and per-extension action URL support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,8 +69,6 @@ from .object import (
|
||||
CreateUploadSessionRequest,
|
||||
DirectoryCreateRequest,
|
||||
DirectoryResponse,
|
||||
FileMetadata,
|
||||
FileMetadataBase,
|
||||
Object,
|
||||
ObjectBase,
|
||||
ObjectCopyRequest,
|
||||
@@ -98,6 +96,24 @@ from .object import (
|
||||
TrashRestoreRequest,
|
||||
TrashDeleteRequest,
|
||||
)
|
||||
from .object_metadata import (
|
||||
ObjectMetadata,
|
||||
ObjectMetadataBase,
|
||||
MetadataNamespace,
|
||||
MetadataResponse,
|
||||
MetadataPatchItem,
|
||||
MetadataPatchRequest,
|
||||
INTERNAL_NAMESPACES,
|
||||
USER_WRITABLE_NAMESPACES,
|
||||
)
|
||||
from .custom_property import (
|
||||
CustomPropertyDefinition,
|
||||
CustomPropertyDefinitionBase,
|
||||
CustomPropertyType,
|
||||
CustomPropertyCreateRequest,
|
||||
CustomPropertyUpdateRequest,
|
||||
CustomPropertyResponse,
|
||||
)
|
||||
from .physical_file import PhysicalFile, PhysicalFileBase
|
||||
from .uri import DiskNextURI, FileSystemNamespace
|
||||
from .order import Order, OrderStatus, OrderType
|
||||
@@ -131,6 +147,7 @@ from .file_app import (
|
||||
FileAppSummary, FileViewersResponse, SetDefaultViewerRequest, UserFileAppDefaultResponse,
|
||||
FileAppCreateRequest, FileAppUpdateRequest, FileAppResponse, FileAppListResponse,
|
||||
ExtensionUpdateRequest, GroupAccessUpdateRequest, WopiSessionResponse,
|
||||
WopiDiscoveredExtension, WopiDiscoveryResponse,
|
||||
)
|
||||
from .wopi import WopiFileInfo, WopiAccessTokenPayload
|
||||
|
||||
|
||||
135
sqlmodels/custom_property.py
Normal file
135
sqlmodels/custom_property.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
用户自定义属性定义模型
|
||||
|
||||
允许用户定义类型化的自定义属性模板(如标签、评分、分类等),
|
||||
实际值通过 ObjectMetadata KV 表存储,键名格式:custom:{property_definition_id}。
|
||||
|
||||
支持的属性类型:text, number, boolean, select, multi_select, rating, link
|
||||
"""
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import JSON
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
# ==================== 枚举 ====================
|
||||
|
||||
class CustomPropertyType(StrEnum):
|
||||
"""自定义属性值类型枚举"""
|
||||
TEXT = "text"
|
||||
"""文本"""
|
||||
NUMBER = "number"
|
||||
"""数字"""
|
||||
BOOLEAN = "boolean"
|
||||
"""布尔值"""
|
||||
SELECT = "select"
|
||||
"""单选"""
|
||||
MULTI_SELECT = "multi_select"
|
||||
"""多选"""
|
||||
RATING = "rating"
|
||||
"""评分(1-5)"""
|
||||
LINK = "link"
|
||||
"""链接"""
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class CustomPropertyDefinitionBase(SQLModelBase):
|
||||
"""自定义属性定义基础模型"""
|
||||
|
||||
name: str = Field(max_length=100)
|
||||
"""属性显示名称"""
|
||||
|
||||
type: CustomPropertyType
|
||||
"""属性值类型"""
|
||||
|
||||
icon: str | None = Field(default=None, max_length=100)
|
||||
"""图标标识(iconify 名称)"""
|
||||
|
||||
options: list[str] | None = Field(default=None, sa_type=JSON)
|
||||
"""可选值列表(仅 select/multi_select 类型)"""
|
||||
|
||||
default_value: str | None = Field(default=None, max_length=500)
|
||||
"""默认值"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class CustomPropertyDefinition(CustomPropertyDefinitionBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
用户自定义属性定义
|
||||
|
||||
每个用户独立管理自己的属性模板。
|
||||
实际属性值存储在 ObjectMetadata 表中,键名格式:custom:{id}。
|
||||
"""
|
||||
|
||||
owner_id: UUID = Field(
|
||||
foreign_key="user.id",
|
||||
ondelete="CASCADE",
|
||||
index=True,
|
||||
)
|
||||
"""所有者用户UUID"""
|
||||
|
||||
sort_order: int = 0
|
||||
"""排序顺序"""
|
||||
|
||||
# 关系
|
||||
owner: "User" = Relationship()
|
||||
"""所有者"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
class CustomPropertyCreateRequest(SQLModelBase):
|
||||
"""创建自定义属性请求 DTO"""
|
||||
|
||||
name: str = Field(max_length=100)
|
||||
"""属性显示名称"""
|
||||
|
||||
type: CustomPropertyType
|
||||
"""属性值类型"""
|
||||
|
||||
icon: str | None = None
|
||||
"""图标标识"""
|
||||
|
||||
options: list[str] | None = None
|
||||
"""可选值列表(仅 select/multi_select 类型)"""
|
||||
|
||||
default_value: str | None = None
|
||||
"""默认值"""
|
||||
|
||||
|
||||
class CustomPropertyUpdateRequest(SQLModelBase):
|
||||
"""更新自定义属性请求 DTO"""
|
||||
|
||||
name: str | None = None
|
||||
"""属性显示名称"""
|
||||
|
||||
icon: str | None = None
|
||||
"""图标标识"""
|
||||
|
||||
options: list[str] | None = None
|
||||
"""可选值列表"""
|
||||
|
||||
default_value: str | None = None
|
||||
"""默认值"""
|
||||
|
||||
sort_order: int | None = None
|
||||
"""排序顺序"""
|
||||
|
||||
|
||||
class CustomPropertyResponse(CustomPropertyDefinitionBase):
|
||||
"""自定义属性响应 DTO"""
|
||||
|
||||
id: UUID
|
||||
"""属性定义UUID"""
|
||||
|
||||
sort_order: int
|
||||
"""排序顺序"""
|
||||
@@ -297,6 +297,29 @@ class WopiSessionResponse(SQLModelBase):
|
||||
"""完整的编辑器 URL"""
|
||||
|
||||
|
||||
class WopiDiscoveredExtension(SQLModelBase):
|
||||
"""单个 WOPI Discovery 发现的扩展名"""
|
||||
|
||||
extension: str
|
||||
"""文件扩展名"""
|
||||
|
||||
action_url: str
|
||||
"""处理后的动作 URL 模板"""
|
||||
|
||||
|
||||
class WopiDiscoveryResponse(SQLModelBase):
|
||||
"""WOPI Discovery 结果响应 DTO"""
|
||||
|
||||
discovered_extensions: list[WopiDiscoveredExtension] = []
|
||||
"""发现的扩展名及其 URL 模板"""
|
||||
|
||||
app_names: list[str] = []
|
||||
"""WOPI 服务端报告的应用名称(如 Writer、Calc、Impress)"""
|
||||
|
||||
applied_count: int = 0
|
||||
"""已应用到 FileAppExtension 的数量"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class FileApp(SQLModelBase, UUIDTableBaseMixin):
|
||||
@@ -377,6 +400,9 @@ class FileAppExtension(SQLModelBase, TableBaseMixin):
|
||||
priority: int = Field(default=0, ge=0)
|
||||
"""排序优先级(越小越优先)"""
|
||||
|
||||
wopi_action_url: str | None = Field(default=None, max_length=2048)
|
||||
"""WOPI 动作 URL 模板(Discovery 自动填充),支持 {wopi_src} {access_token} {access_token_ttl}"""
|
||||
|
||||
# 关系
|
||||
app: FileApp = Relationship(back_populates="extensions")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
||||
from .share import Share
|
||||
from .physical_file import PhysicalFile
|
||||
from .uri import DiskNextURI
|
||||
from .object_metadata import ObjectMetadata
|
||||
|
||||
|
||||
class ObjectType(StrEnum):
|
||||
@@ -26,31 +27,6 @@ class ObjectType(StrEnum):
|
||||
FOLDER = "folder"
|
||||
|
||||
|
||||
class FileMetadataBase(SQLModelBase):
|
||||
"""文件元数据基础模型"""
|
||||
|
||||
width: int | None = Field(default=None)
|
||||
"""图片宽度(像素)"""
|
||||
|
||||
height: int | None = Field(default=None)
|
||||
"""图片高度(像素)"""
|
||||
|
||||
duration: float | None = Field(default=None)
|
||||
"""音视频时长(秒)"""
|
||||
|
||||
bitrate: int | None = Field(default=None)
|
||||
"""比特率(kbps)"""
|
||||
|
||||
mime_type: str | None = Field(default=None, max_length=127)
|
||||
"""MIME类型"""
|
||||
|
||||
checksum_md5: str | None = Field(default=None, max_length=32)
|
||||
"""MD5校验和"""
|
||||
|
||||
checksum_sha256: str | None = Field(default=None, max_length=64)
|
||||
"""SHA256校验和"""
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class ObjectBase(SQLModelBase):
|
||||
@@ -65,6 +41,9 @@ class ObjectBase(SQLModelBase):
|
||||
size: int | None = None
|
||||
"""文件大小(字节),目录为 None"""
|
||||
|
||||
mime_type: str | None = Field(default=None, max_length=127)
|
||||
"""MIME类型(仅文件有效)"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
@@ -174,22 +153,6 @@ class DirectoryResponse(SQLModelBase):
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class FileMetadata(FileMetadataBase, UUIDTableBaseMixin):
|
||||
"""文件元数据模型(与Object一对一关联)"""
|
||||
|
||||
object_id: UUID = Field(
|
||||
foreign_key="object.id",
|
||||
unique=True,
|
||||
index=True,
|
||||
ondelete="CASCADE"
|
||||
)
|
||||
"""关联的对象UUID"""
|
||||
|
||||
# 反向关系
|
||||
object: "Object" = Relationship(back_populates="file_metadata")
|
||||
"""关联的对象"""
|
||||
|
||||
|
||||
class Object(ObjectBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
统一对象模型
|
||||
@@ -344,11 +307,11 @@ class Object(ObjectBase, UUIDTableBaseMixin):
|
||||
"""子对象(文件和子目录)"""
|
||||
|
||||
# 仅文件有效的关系
|
||||
file_metadata: FileMetadata | None = Relationship(
|
||||
metadata_entries: list["ObjectMetadata"] = Relationship(
|
||||
back_populates="object",
|
||||
sa_relationship_kwargs={"uselist": False, "cascade": "all, delete-orphan"},
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
"""文件元数据(仅文件有效)"""
|
||||
"""元数据键值对列表"""
|
||||
|
||||
source_links: list["SourceLink"] = Relationship(
|
||||
back_populates="object",
|
||||
@@ -775,6 +738,9 @@ class ObjectPropertyResponse(SQLModelBase):
|
||||
size: int
|
||||
"""文件大小(字节)"""
|
||||
|
||||
mime_type: str | None = None
|
||||
"""MIME类型"""
|
||||
|
||||
created_at: datetime
|
||||
"""创建时间"""
|
||||
|
||||
@@ -788,22 +754,13 @@ class ObjectPropertyResponse(SQLModelBase):
|
||||
class ObjectPropertyDetailResponse(ObjectPropertyResponse):
|
||||
"""对象详细属性响应 DTO(继承基本属性)"""
|
||||
|
||||
# 元数据信息
|
||||
mime_type: str | None = None
|
||||
"""MIME类型"""
|
||||
|
||||
width: int | None = None
|
||||
"""图片宽度(像素)"""
|
||||
|
||||
height: int | None = None
|
||||
"""图片高度(像素)"""
|
||||
|
||||
duration: float | None = None
|
||||
"""音视频时长(秒)"""
|
||||
|
||||
# 校验和(从 PhysicalFile 读取)
|
||||
checksum_md5: str | None = None
|
||||
"""MD5校验和"""
|
||||
|
||||
checksum_sha256: str | None = None
|
||||
"""SHA256校验和"""
|
||||
|
||||
# 分享统计
|
||||
share_count: int = 0
|
||||
"""分享次数"""
|
||||
@@ -821,6 +778,10 @@ class ObjectPropertyDetailResponse(ObjectPropertyResponse):
|
||||
reference_count: int = 1
|
||||
"""物理文件引用计数(仅文件有效)"""
|
||||
|
||||
# 元数据(KV 格式)
|
||||
metadatas: dict[str, str] = {}
|
||||
"""所有元数据条目(键名 → 值)"""
|
||||
|
||||
|
||||
# ==================== 管理员文件管理 DTO ====================
|
||||
|
||||
|
||||
127
sqlmodels/object_metadata.py
Normal file
127
sqlmodels/object_metadata.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
对象元数据 KV 模型
|
||||
|
||||
以键值对形式存储文件的扩展元数据。键名使用命名空间前缀分类,
|
||||
如 exif:width, stream:duration, music:artist 等。
|
||||
|
||||
架构:
|
||||
ObjectMetadata (KV 表,与 Object 一对多关系)
|
||||
└── 每个 Object 可以有多条元数据记录
|
||||
└── (object_id, name) 组合唯一索引
|
||||
|
||||
命名空间:
|
||||
- exif: 图片 EXIF 信息(尺寸、相机参数、拍摄时间等)
|
||||
- stream: 音视频流信息(时长、比特率、视频尺寸、编解码等)
|
||||
- music: 音乐标签(标题、艺术家、专辑等)
|
||||
- geo: 地理位置(经纬度、地址)
|
||||
- apk: Android 安装包信息
|
||||
- custom: 用户自定义属性
|
||||
- sys: 系统内部元数据
|
||||
- thumb: 缩略图信息
|
||||
"""
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Field, UniqueConstraint, Index, Relationship
|
||||
|
||||
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .object import Object
|
||||
|
||||
|
||||
# ==================== 枚举 ====================
|
||||
|
||||
class MetadataNamespace(StrEnum):
|
||||
"""元数据命名空间枚举"""
|
||||
EXIF = "exif"
|
||||
"""图片 EXIF 信息(含尺寸、相机参数、拍摄时间等)"""
|
||||
MUSIC = "music"
|
||||
"""音乐标签(title/artist/album/genre 等)"""
|
||||
STREAM = "stream"
|
||||
"""音视频流信息(codec/duration/bitrate/resolution 等)"""
|
||||
GEO = "geo"
|
||||
"""地理位置(latitude/longitude/address)"""
|
||||
APK = "apk"
|
||||
"""Android 安装包信息(package_name/version 等)"""
|
||||
THUMB = "thumb"
|
||||
"""缩略图信息(内部使用)"""
|
||||
SYS = "sys"
|
||||
"""系统元数据(内部使用)"""
|
||||
CUSTOM = "custom"
|
||||
"""用户自定义属性"""
|
||||
|
||||
|
||||
# 对外不可见的命名空间(API 不返回给普通用户)
|
||||
INTERNAL_NAMESPACES: set[str] = {MetadataNamespace.SYS, MetadataNamespace.THUMB}
|
||||
|
||||
# 用户可写的命名空间
|
||||
USER_WRITABLE_NAMESPACES: set[str] = {MetadataNamespace.CUSTOM}
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class ObjectMetadataBase(SQLModelBase):
|
||||
"""对象元数据 KV 基础模型"""
|
||||
|
||||
name: str = Field(max_length=255)
|
||||
"""元数据键名,格式:namespace:key(如 exif:width, stream:duration)"""
|
||||
|
||||
value: str
|
||||
"""元数据值(统一为字符串存储)"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class ObjectMetadata(ObjectMetadataBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
对象元数据 KV 模型
|
||||
|
||||
以键值对形式存储文件的扩展元数据。键名使用命名空间前缀分类,
|
||||
每个对象的每个键名唯一(通过唯一索引保证)。
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("object_id", "name", name="uq_object_metadata_object_name"),
|
||||
Index("ix_object_metadata_object_id", "object_id"),
|
||||
)
|
||||
|
||||
object_id: UUID = Field(
|
||||
foreign_key="object.id",
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
"""关联的对象UUID"""
|
||||
|
||||
is_public: bool = False
|
||||
"""是否对分享页面公开"""
|
||||
|
||||
# 关系
|
||||
object: "Object" = Relationship(back_populates="metadata_entries")
|
||||
"""关联的对象"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
class MetadataResponse(SQLModelBase):
|
||||
"""元数据查询响应 DTO"""
|
||||
|
||||
metadatas: dict[str, str]
|
||||
"""元数据字典(键名 → 值)"""
|
||||
|
||||
|
||||
class MetadataPatchItem(SQLModelBase):
|
||||
"""单条元数据补丁 DTO"""
|
||||
|
||||
key: str = Field(max_length=255)
|
||||
"""元数据键名"""
|
||||
|
||||
value: str | None = None
|
||||
"""值,None 表示删除此条目"""
|
||||
|
||||
|
||||
class MetadataPatchRequest(SQLModelBase):
|
||||
"""元数据批量更新请求 DTO"""
|
||||
|
||||
patches: list[MetadataPatchItem]
|
||||
"""补丁列表"""
|
||||
@@ -34,6 +34,9 @@ class PhysicalFileBase(SQLModelBase):
|
||||
checksum_md5: str | None = Field(default=None, max_length=32)
|
||||
"""MD5校验和(用于文件去重和完整性校验)"""
|
||||
|
||||
checksum_sha256: str | None = Field(default=None, max_length=64)
|
||||
"""SHA256校验和"""
|
||||
|
||||
|
||||
class PhysicalFile(PhysicalFileBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user