feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
All checks were successful
Test / test (push) Successful in 1m45s

- Migrate SQLModel base classes, mixins, and database management to
  external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/,
  and sqlmodels/database.py
- Add file viewer/editor system with WOPI protocol support for
  collaborative editing (OnlyOffice, Collabora)
- Add enterprise edition license verification module (ee/)
- Add Dockerfile multi-stage build with Cython compilation support
- Add new dependencies: sqlmodel-ext, cryptography, whatthepatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 14:23:17 +08:00
parent 53b757de7a
commit ccadfe57cd
81 changed files with 5106 additions and 4837 deletions

409
sqlmodels/file_app.py Normal file
View File

@@ -0,0 +1,409 @@
"""
文件查看器应用模块
提供文件预览应用选择器系统的数据模型和 DTO。
类似 Android 的"使用什么应用打开"机制:
- 管理员注册应用(内置/iframe/WOPI
- 用户按扩展名查询可用查看器
- 用户可设置"始终使用"偏好
- 支持用户组级别的访问控制
架构:
FileApp (应用注册表)
├── FileAppExtension (扩展名关联)
├── FileAppGroupLink (用户组访问控制)
└── UserFileAppDefault (用户默认偏好)
"""
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin
if TYPE_CHECKING:
from .group import Group
# ==================== 枚举 ====================
class FileAppType(StrEnum):
"""文件应用类型"""
BUILTIN = "builtin"
"""前端内置查看器(如 pdf.js, Monaco"""
IFRAME = "iframe"
"""iframe 内嵌第三方服务"""
WOPI = "wopi"
"""WOPI 协议OnlyOffice / Collabora"""
# ==================== Link 表 ====================
class FileAppGroupLink(SQLModelBase, table=True):
"""应用-用户组访问控制关联表"""
app_id: UUID = Field(foreign_key="fileapp.id", primary_key=True, ondelete="CASCADE")
"""关联的应用UUID"""
group_id: UUID = Field(foreign_key="group.id", primary_key=True, ondelete="CASCADE")
"""关联的用户组UUID"""
# ==================== DTO 模型 ====================
class FileAppSummary(SQLModelBase):
"""查看器列表项 DTO用于选择器弹窗"""
id: UUID
"""应用UUID"""
name: str
"""应用名称"""
app_key: str
"""应用唯一标识"""
type: FileAppType
"""应用类型"""
icon: str | None = None
"""图标名称/URL"""
description: str | None = None
"""应用描述"""
iframe_url_template: str | None = None
"""iframe URL 模板"""
wopi_editor_url_template: str | None = None
"""WOPI 编辑器 URL 模板"""
class FileViewersResponse(SQLModelBase):
"""查看器查询响应 DTO"""
viewers: list[FileAppSummary] = []
"""可用查看器列表(已按 priority 排序)"""
default_viewer_id: UUID | None = None
"""用户默认查看器UUID如果已设置"始终使用""""
class SetDefaultViewerRequest(SQLModelBase):
"""设置默认查看器请求 DTO"""
extension: str = Field(max_length=20)
"""文件扩展名(小写,无点号)"""
app_id: UUID
"""应用UUID"""
class UserFileAppDefaultResponse(SQLModelBase):
"""用户默认查看器响应 DTO"""
id: UUID
"""记录UUID"""
extension: str
"""扩展名"""
app: FileAppSummary
"""关联的应用摘要"""
class FileAppCreateRequest(SQLModelBase):
"""管理员创建应用请求 DTO"""
name: str = Field(max_length=100)
"""应用名称"""
app_key: str = Field(max_length=50)
"""应用唯一标识"""
type: FileAppType
"""应用类型"""
icon: str | None = Field(default=None, max_length=255)
"""图标名称/URL"""
description: str | None = Field(default=None, max_length=500)
"""应用描述"""
is_enabled: bool = True
"""是否启用"""
is_restricted: bool = False
"""是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024)
"""iframe URL 模板"""
wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024)
"""WOPI 编辑器 URL 模板"""
extensions: list[str] = []
"""关联的扩展名列表"""
allowed_group_ids: list[UUID] = []
"""允许访问的用户组UUID列表"""
class FileAppUpdateRequest(SQLModelBase):
"""管理员更新应用请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=100)
"""应用名称"""
app_key: str | None = Field(default=None, max_length=50)
"""应用唯一标识"""
type: FileAppType | None = None
"""应用类型"""
icon: str | None = Field(default=None, max_length=255)
"""图标名称/URL"""
description: str | None = Field(default=None, max_length=500)
"""应用描述"""
is_enabled: bool | None = None
"""是否启用"""
is_restricted: bool | None = None
"""是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024)
"""iframe URL 模板"""
wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024)
"""WOPI 编辑器 URL 模板"""
class FileAppResponse(SQLModelBase):
"""管理员应用详情响应 DTO"""
id: UUID
"""应用UUID"""
name: str
"""应用名称"""
app_key: str
"""应用唯一标识"""
type: FileAppType
"""应用类型"""
icon: str | None = None
"""图标名称/URL"""
description: str | None = None
"""应用描述"""
is_enabled: bool = True
"""是否启用"""
is_restricted: bool = False
"""是否限制用户组访问"""
iframe_url_template: str | None = None
"""iframe URL 模板"""
wopi_discovery_url: str | None = None
"""WOPI 发现端点 URL"""
wopi_editor_url_template: str | None = None
"""WOPI 编辑器 URL 模板"""
extensions: list[str] = []
"""关联的扩展名列表"""
allowed_group_ids: list[UUID] = []
"""允许访问的用户组UUID列表"""
@classmethod
def from_app(
cls,
app: "FileApp",
extensions: list["FileAppExtension"],
group_links: list[FileAppGroupLink],
) -> "FileAppResponse":
"""从 ORM 对象构建 DTO"""
return cls(
id=app.id,
name=app.name,
app_key=app.app_key,
type=app.type,
icon=app.icon,
description=app.description,
is_enabled=app.is_enabled,
is_restricted=app.is_restricted,
iframe_url_template=app.iframe_url_template,
wopi_discovery_url=app.wopi_discovery_url,
wopi_editor_url_template=app.wopi_editor_url_template,
extensions=[ext.extension for ext in extensions],
allowed_group_ids=[link.group_id for link in group_links],
)
class FileAppListResponse(SQLModelBase):
"""管理员应用列表响应 DTO"""
apps: list[FileAppResponse] = []
"""应用列表"""
total: int = 0
"""总数"""
class ExtensionUpdateRequest(SQLModelBase):
"""扩展名全量替换请求 DTO"""
extensions: list[str]
"""扩展名列表(小写,无点号)"""
class GroupAccessUpdateRequest(SQLModelBase):
"""用户组权限全量替换请求 DTO"""
group_ids: list[UUID]
"""允许访问的用户组UUID列表"""
class WopiSessionResponse(SQLModelBase):
"""WOPI 会话响应 DTO"""
wopi_src: str
"""WOPI 源 URL"""
access_token: str
"""WOPI 访问令牌"""
access_token_ttl: int
"""令牌过期时间戳毫秒WOPI 规范要求)"""
editor_url: str
"""完整的编辑器 URL"""
# ==================== 数据库模型 ====================
class FileApp(SQLModelBase, UUIDTableBaseMixin):
"""文件查看器应用注册表"""
name: str = Field(max_length=100)
"""应用名称"""
app_key: str = Field(max_length=50, unique=True, index=True)
"""应用唯一标识,前端路由用"""
type: FileAppType
"""应用类型"""
icon: str | None = Field(default=None, max_length=255)
"""图标名称/URL"""
description: str | None = Field(default=None, max_length=500)
"""应用描述"""
is_enabled: bool = True
"""是否启用"""
is_restricted: bool = False
"""是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024)
"""iframe URL 模板,支持 {file_url} 占位符"""
wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 客户端发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024)
"""WOPI 编辑器 URL 模板,支持 {wopi_src} {access_token} {access_token_ttl}"""
# 关系
extensions: list["FileAppExtension"] = Relationship(
back_populates="app",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
user_defaults: list["UserFileAppDefault"] = Relationship(
back_populates="app",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
allowed_groups: list["Group"] = Relationship(
link_model=FileAppGroupLink,
)
def to_summary(self) -> FileAppSummary:
"""转换为摘要 DTO"""
return FileAppSummary(
id=self.id,
name=self.name,
app_key=self.app_key,
type=self.type,
icon=self.icon,
description=self.description,
iframe_url_template=self.iframe_url_template,
wopi_editor_url_template=self.wopi_editor_url_template,
)
class FileAppExtension(SQLModelBase, TableBaseMixin):
"""扩展名关联表"""
__table_args__ = (
UniqueConstraint("app_id", "extension", name="uq_fileappextension_app_extension"),
)
app_id: UUID = Field(foreign_key="fileapp.id", index=True, ondelete="CASCADE")
"""关联的应用UUID"""
extension: str = Field(max_length=20, index=True)
"""扩展名(小写,无点号)"""
priority: int = Field(default=0, ge=0)
"""排序优先级(越小越优先)"""
# 关系
app: FileApp = Relationship(back_populates="extensions")
class UserFileAppDefault(SQLModelBase, UUIDTableBaseMixin):
"""用户"始终使用"偏好"""
__table_args__ = (
UniqueConstraint("user_id", "extension", name="uq_userfileappdefault_user_extension"),
)
user_id: UUID = Field(foreign_key="user.id", index=True, ondelete="CASCADE")
"""用户UUID"""
extension: str = Field(max_length=20)
"""扩展名(小写,无点号)"""
app_id: UUID = Field(foreign_key="fileapp.id", index=True, ondelete="CASCADE")
"""关联的应用UUID"""
# 关系
app: FileApp = Relationship(back_populates="user_defaults")
def to_response(self) -> UserFileAppDefaultResponse:
"""转换为响应 DTO需预加载 app 关系)"""
return UserFileAppDefaultResponse(
id=self.id,
extension=self.extension,
app=self.app.to_summary(),
)