feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration

- 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 eac0766e79
74 changed files with 4819 additions and 4837 deletions

View File

@@ -1,6 +1,8 @@
from fastapi import APIRouter
from .api import router as api_router
from .wopi import wopi_router
router = APIRouter()
router.include_router(api_router)
router.include_router(api_router)
router.include_router(wopi_router)

View File

@@ -9,7 +9,7 @@ from sqlmodels import (
User, ResponseBase,
Setting, Object, ObjectType, Share, AdminSummaryResponse, MetricsSummary, LicenseInfo, VersionInfo,
)
from sqlmodels.base import SQLModelBase
from sqlmodel_ext import SQLModelBase
from sqlmodels.setting import (
SettingItem, SettingsListResponse, SettingsUpdateRequest, SettingsUpdateResponse,
)
@@ -17,6 +17,7 @@ from sqlmodels.setting import SettingsType
from utils import http_exceptions
from utils.conf import appmeta
from .file import admin_file_router
from .file_app import admin_file_app_router
from .group import admin_group_router
from .policy import admin_policy_router
from .share import admin_share_router
@@ -44,6 +45,7 @@ admin_router = APIRouter(
admin_router.include_router(admin_group_router)
admin_router.include_router(admin_user_router)
admin_router.include_router(admin_file_router)
admin_router.include_router(admin_file_app_router)
admin_router.include_router(admin_policy_router)
admin_router.include_router(admin_share_router)
admin_router.include_router(admin_task_router)

View File

@@ -0,0 +1,348 @@
"""
管理员文件应用管理端点
提供文件查看器应用的 CRUD、扩展名管理和用户组权限管理。
"""
from uuid import UUID
from fastapi import APIRouter, Depends, status
from loguru import logger as l
from sqlalchemy import select
from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep
from sqlmodels import (
FileApp,
FileAppCreateRequest,
FileAppExtension,
FileAppGroupLink,
FileAppListResponse,
FileAppResponse,
FileAppUpdateRequest,
ExtensionUpdateRequest,
GroupAccessUpdateRequest,
)
from utils import http_exceptions
admin_file_app_router = APIRouter(
prefix="/file-app",
tags=["admin", "file_app"],
dependencies=[Depends(admin_required)],
)
@admin_file_app_router.get(
path='/list',
summary='列出所有文件应用',
)
async def list_file_apps(
session: SessionDep,
table_view: TableViewRequestDep,
) -> FileAppListResponse:
"""
列出所有文件应用端点(分页)
认证:管理员权限
"""
result = await FileApp.get_with_count(
session,
table_view=table_view,
)
apps: list[FileAppResponse] = []
for app in result.items:
extensions = await FileAppExtension.get(
session,
FileAppExtension.app_id == app.id,
fetch_mode="all",
)
group_links_result = await session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
)
group_links: list[FileAppGroupLink] = list(group_links_result.all())
apps.append(FileAppResponse.from_app(app, extensions, group_links))
return FileAppListResponse(apps=apps, total=result.count)
@admin_file_app_router.post(
path='/',
summary='创建文件应用',
status_code=status.HTTP_201_CREATED,
)
async def create_file_app(
session: SessionDep,
request: FileAppCreateRequest,
) -> FileAppResponse:
"""
创建文件应用端点
认证:管理员权限
错误处理:
- 409: app_key 已存在
"""
# 检查 app_key 唯一
existing = await FileApp.get(session, FileApp.app_key == request.app_key)
if existing:
http_exceptions.raise_conflict(f"应用标识 '{request.app_key}' 已存在")
# 创建应用
app = FileApp(
name=request.name,
app_key=request.app_key,
type=request.type,
icon=request.icon,
description=request.description,
is_enabled=request.is_enabled,
is_restricted=request.is_restricted,
iframe_url_template=request.iframe_url_template,
wopi_discovery_url=request.wopi_discovery_url,
wopi_editor_url_template=request.wopi_editor_url_template,
)
app = await app.save(session)
app_id = app.id
# 创建扩展名关联
extensions: list[FileAppExtension] = []
for i, ext in enumerate(request.extensions):
normalized = ext.lower().strip().lstrip('.')
ext_record = FileAppExtension(
app_id=app_id,
extension=normalized,
priority=i,
)
ext_record = await ext_record.save(session)
extensions.append(ext_record)
# 创建用户组关联
group_links: list[FileAppGroupLink] = []
for group_id in request.allowed_group_ids:
link = FileAppGroupLink(app_id=app_id, group_id=group_id)
session.add(link)
group_links.append(link)
if group_links:
await session.commit()
l.info(f"创建文件应用: {app.name} ({app.app_key})")
return FileAppResponse.from_app(app, extensions, group_links)
@admin_file_app_router.get(
path='/{app_id}',
summary='获取文件应用详情',
)
async def get_file_app(
session: SessionDep,
app_id: UUID,
) -> FileAppResponse:
"""
获取文件应用详情端点
认证:管理员权限
错误处理:
- 404: 应用不存在
"""
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
extensions = await FileAppExtension.get(
session,
FileAppExtension.app_id == app.id,
fetch_mode="all",
)
group_links_result = await session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
)
group_links: list[FileAppGroupLink] = list(group_links_result.all())
return FileAppResponse.from_app(app, extensions, group_links)
@admin_file_app_router.patch(
path='/{app_id}',
summary='更新文件应用',
)
async def update_file_app(
session: SessionDep,
app_id: UUID,
request: FileAppUpdateRequest,
) -> FileAppResponse:
"""
更新文件应用端点
认证:管理员权限
错误处理:
- 404: 应用不存在
- 409: 新 app_key 已被其他应用使用
"""
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 检查 app_key 唯一性
if request.app_key is not None and request.app_key != app.app_key:
existing = await FileApp.get(session, FileApp.app_key == request.app_key)
if existing:
http_exceptions.raise_conflict(f"应用标识 '{request.app_key}' 已存在")
# 更新非 None 字段
update_data = request.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(app, key, value)
app = await app.save(session)
extensions = await FileAppExtension.get(
session,
FileAppExtension.app_id == app.id,
fetch_mode="all",
)
group_links_result = await session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
)
group_links: list[FileAppGroupLink] = list(group_links_result.all())
l.info(f"更新文件应用: {app.name} ({app.app_key})")
return FileAppResponse.from_app(app, extensions, group_links)
@admin_file_app_router.delete(
path='/{app_id}',
summary='删除文件应用',
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_file_app(
session: SessionDep,
app_id: UUID,
) -> None:
"""
删除文件应用端点(级联删除扩展名、用户偏好和用户组关联)
认证:管理员权限
错误处理:
- 404: 应用不存在
"""
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
app_name = app.app_key
await FileApp.delete(session, app)
l.info(f"删除文件应用: {app_name}")
@admin_file_app_router.put(
path='/{app_id}/extensions',
summary='全量替换扩展名列表',
)
async def update_extensions(
session: SessionDep,
app_id: UUID,
request: ExtensionUpdateRequest,
) -> FileAppResponse:
"""
全量替换扩展名列表端点
先删除旧的扩展名关联,再创建新的。
认证:管理员权限
错误处理:
- 404: 应用不存在
"""
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 删除旧的扩展名
old_extensions: list[FileAppExtension] = await FileAppExtension.get(
session,
FileAppExtension.app_id == app_id,
fetch_mode="all",
)
for old_ext in old_extensions:
await FileAppExtension.delete(session, old_ext, commit=False)
# 创建新的扩展名
new_extensions: list[FileAppExtension] = []
for i, ext in enumerate(request.extensions):
normalized = ext.lower().strip().lstrip('.')
ext_record = FileAppExtension(
app_id=app_id,
extension=normalized,
priority=i,
)
session.add(ext_record)
new_extensions.append(ext_record)
await session.commit()
# refresh 新创建的记录
for ext_record in new_extensions:
await session.refresh(ext_record)
group_links_result = await session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app_id)
)
group_links: list[FileAppGroupLink] = list(group_links_result.all())
l.info(f"更新文件应用 {app.app_key} 的扩展名: {request.extensions}")
return FileAppResponse.from_app(app, new_extensions, group_links)
@admin_file_app_router.put(
path='/{app_id}/groups',
summary='全量替换允许的用户组',
)
async def update_group_access(
session: SessionDep,
app_id: UUID,
request: GroupAccessUpdateRequest,
) -> FileAppResponse:
"""
全量替换允许的用户组端点
先删除旧的关联,再创建新的。
认证:管理员权限
错误处理:
- 404: 应用不存在
"""
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 删除旧的用户组关联
old_links_result = await session.exec(
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app_id)
)
old_links: list[FileAppGroupLink] = list(old_links_result.all())
for old_link in old_links:
await session.delete(old_link)
# 创建新的用户组关联
new_links: list[FileAppGroupLink] = []
for group_id in request.group_ids:
link = FileAppGroupLink(app_id=app_id, group_id=group_id)
session.add(link)
new_links.append(link)
await session.commit()
extensions = await FileAppExtension.get(
session,
FileAppExtension.app_id == app_id,
fetch_mode="all",
)
l.info(f"更新文件应用 {app.app_key} 的用户组权限: {request.group_ids}")
return FileAppResponse.from_app(app, extensions, new_links)

View File

@@ -9,7 +9,7 @@ from middleware.dependencies import SessionDep, TableViewRequestDep
from sqlmodels import (
Policy, PolicyBase, PolicyType, PolicySummary, ResponseBase,
ListResponse, Object, )
from sqlmodels.base import SQLModelBase
from sqlmodel_ext import SQLModelBase
from service.storage import DirectoryCreationError, LocalStorageService
admin_policy_router = APIRouter(

View File

@@ -8,47 +8,92 @@
- /file/upload - 上传相关操作
- /file/download - 下载相关操作
"""
import hashlib
from datetime import datetime, timedelta
from typing import Annotated
from uuid import UUID
import whatthepatch
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from loguru import logger as l
from sqlmodel_ext import SQLModelBase
from whatthepatch.exceptions import HunkApplyException
from middleware.auth import auth_required, verify_download_token
from middleware.dependencies import SessionDep
from sqlmodels import (
CreateFileRequest,
CreateUploadSessionRequest,
FileApp,
FileAppExtension,
FileAppGroupLink,
FileAppType,
Object,
ObjectType,
PhysicalFile,
Policy,
PolicyType,
ResponseBase,
Setting,
SettingsType,
UploadChunkResponse,
UploadSession,
UploadSessionResponse,
User,
WopiSessionResponse,
)
from service.storage import LocalStorageService, adjust_user_storage
from service.redis.token_store import TokenStore
from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL
from utils.JWT.wopi_token import create_wopi_token
from utils import http_exceptions
from .viewers import viewers_router
# DTO
class DownloadTokenModel(ResponseBase):
"""下载Token响应模型"""
access_token: str
"""JWT 令牌"""
expires_in: int
"""过期时间(秒)"""
class TextContentResponse(ResponseBase):
"""文本文件内容响应"""
content: str
"""文件文本内容UTF-8"""
hash: str
"""SHA-256 hex"""
size: int
"""文件字节大小"""
class PatchContentRequest(SQLModelBase):
"""增量保存请求"""
patch: str
"""unified diff 文本"""
base_hash: str
"""原始内容的 SHA-256 hex64字符"""
class PatchContentResponse(ResponseBase):
"""增量保存响应"""
new_hash: str
"""新内容的 SHA-256 hex"""
new_size: int
"""新文件字节大小"""
# ==================== 主路由 ====================
router = APIRouter(prefix="/file", tags=["file"])
@@ -410,7 +455,7 @@ async def create_download_token_endpoint(
@_download_router.get(
path='/{token}',
summary='下载文件',
description='使用下载令牌下载文件(一次性令牌,仅可使用一次)',
description='使用下载令牌下载文件,令牌在有效期内可重复使用',
)
async def download_file(
session: SessionDep,
@@ -420,19 +465,14 @@ async def download_file(
下载文件端点
验证 JWT 令牌后返回文件内容。
令牌为一次性使用,下载后即失效
令牌在有效期内可重复使用(支持浏览器 range 请求等场景)
"""
# 验证令牌
result = verify_download_token(token)
if not result:
raise HTTPException(status_code=401, detail="下载令牌无效或已过期")
jti, file_id, owner_id = result
# 检查并标记令牌已使用(原子操作)
is_first_use = await TokenStore.mark_used(jti, DOWNLOAD_TOKEN_TTL)
if not is_first_use:
raise HTTPException(status_code=404)
_, file_id, owner_id = result
# 获取文件对象(排除已删除的)
file_obj = await Object.get(
@@ -478,6 +518,7 @@ async def download_file(
router.include_router(_upload_router)
router.include_router(_download_router)
router.include_router(viewers_router)
# ==================== 创建空白文件 ====================
@@ -574,6 +615,113 @@ async def create_empty_file(
})
# ==================== WOPI 会话 ====================
@router.post(
path='/{file_id}/wopi-session',
summary='创建 WOPI 会话',
description='为 WOPI 类型的查看器创建编辑会话,返回编辑器 URL 和访问令牌。',
)
async def create_wopi_session(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
file_id: UUID,
) -> WopiSessionResponse:
"""
创建 WOPI 会话端点
流程:
1. 验证文件存在且属于当前用户
2. 查找文件扩展名对应的 WOPI 类型应用
3. 检查用户组权限
4. 生成 WOPI access token
5. 构建 editor URL
认证JWT token 必填
错误处理:
- 404: 文件不存在 / 无可用 WOPI 应用
- 403: 用户组无权限
"""
# 验证文件
file_obj: Object | None = await Object.get(
session,
Object.id == file_id,
)
if not file_obj or file_obj.owner_id != user.id:
http_exceptions.raise_not_found("文件不存在")
if not file_obj.is_file:
http_exceptions.raise_bad_request("对象不是文件")
# 获取文件扩展名
name_parts = file_obj.name.rsplit('.', 1)
if len(name_parts) < 2:
http_exceptions.raise_bad_request("文件无扩展名,无法使用 WOPI 查看器")
ext = name_parts[1].lower()
# 查找 WOPI 类型的应用
from sqlalchemy import and_, select
ext_records: list[FileAppExtension] = await FileAppExtension.get(
session,
FileAppExtension.extension == ext,
fetch_mode="all",
load=FileAppExtension.app,
)
wopi_app: FileApp | None = None
for ext_record in ext_records:
app = ext_record.app
if app.type == FileAppType.WOPI and app.is_enabled:
# 检查用户组权限FileAppGroupLink 是纯关联表,使用 session 查询)
if app.is_restricted:
stmt = select(FileAppGroupLink).where(
and_(
FileAppGroupLink.app_id == app.id,
FileAppGroupLink.group_id == user.group_id,
)
)
result = await session.exec(stmt)
if not result.first():
continue
wopi_app = app
break
if not wopi_app:
http_exceptions.raise_not_found("无可用的 WOPI 查看器")
if not wopi_app.wopi_editor_url_template:
http_exceptions.raise_bad_request("WOPI 应用未配置编辑器 URL 模板")
# 获取站点 URL
site_url_setting: Setting | None = await Setting.get(
session,
(Setting.type == SettingsType.BASIC) & (Setting.name == "siteURL"),
)
site_url = site_url_setting.value if site_url_setting else "http://localhost"
# 生成 WOPI token
can_write = file_obj.owner_id == user.id
token, access_token_ttl = create_wopi_token(file_id, user.id, can_write)
# 构建 wopi_src
wopi_src = f"{site_url}/wopi/files/{file_id}"
# 构建 editor URL
editor_url = wopi_app.wopi_editor_url_template.format(
wopi_src=wopi_src,
access_token=token,
access_token_ttl=access_token_ttl,
)
return WopiSessionResponse(
wopi_src=wopi_src,
access_token=token,
access_token_ttl=access_token_ttl,
editor_url=editor_url,
)
# ==================== 文件外链(保留原有端点结构) ====================
@router.get(
@@ -612,36 +760,171 @@ async def file_update(id: str) -> ResponseBase:
@router.get(
path='/preview/{id}',
summary='预览文件',
description='获取文件预览。',
dependencies=[Depends(auth_required)]
)
async def file_preview(id: str) -> ResponseBase:
"""预览文件"""
raise HTTPException(status_code=501, detail="预览功能暂未实现")
@router.get(
path='/content/{id}',
path='/content/{file_id}',
summary='获取文本文件内容',
description='获取文本文件内容',
dependencies=[Depends(auth_required)]
description='获取文本文件的 UTF-8 内容和 SHA-256 哈希值',
)
async def file_content(id: str) -> ResponseBase:
"""获取文本文件内容"""
raise HTTPException(status_code=501, detail="文本内容功能暂未实现")
async def file_content(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
file_id: UUID,
) -> TextContentResponse:
"""
获取文本文件内容端点
返回文件的 UTF-8 文本内容和基于规范化内容的 SHA-256 哈希值。
换行符统一规范化为 ``\\n``。
认证JWT token 必填
错误处理:
- 400: 文件不是有效的 UTF-8 文本
- 404: 文件不存在
"""
file_obj = await Object.get(
session,
(Object.id == file_id) & (Object.deleted_at == None)
)
if not file_obj or file_obj.owner_id != user.id:
http_exceptions.raise_not_found("文件不存在")
if not file_obj.is_file:
http_exceptions.raise_bad_request("对象不是文件")
physical_file = await file_obj.awaitable_attrs.physical_file
if not physical_file or not physical_file.storage_path:
http_exceptions.raise_internal_error("文件存储路径丢失")
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
if not policy:
http_exceptions.raise_internal_error("存储策略不存在")
if policy.type != PolicyType.LOCAL:
http_exceptions.raise_not_implemented("S3 存储暂未实现")
storage_service = LocalStorageService(policy)
raw_bytes = await storage_service.read_file(physical_file.storage_path)
try:
content = raw_bytes.decode('utf-8')
except UnicodeDecodeError:
http_exceptions.raise_bad_request("文件不是有效的 UTF-8 文本")
# 换行符规范化
content = content.replace('\r\n', '\n').replace('\r', '\n')
normalized_bytes = content.encode('utf-8')
hash_hex = hashlib.sha256(normalized_bytes).hexdigest()
return TextContentResponse(
content=content,
hash=hash_hex,
size=len(normalized_bytes),
)
@router.get(
path='/doc/{id}',
summary='获取Office文档预览地址',
description='获取Office文档在线预览地址',
dependencies=[Depends(auth_required)]
@router.patch(
path='/content/{file_id}',
summary='增量保存文本文件',
description='使用 unified diff 增量更新文本文件内容',
)
async def file_doc(id: str) -> ResponseBase:
"""获取Office文档预览地址"""
raise HTTPException(status_code=501, detail="Office预览功能暂未实现")
async def patch_file_content(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
file_id: UUID,
request: PatchContentRequest,
) -> PatchContentResponse:
"""
增量保存文本文件端点
接收 unified diff 和 base_hash验证无并发冲突后应用 patch。
认证JWT token 必填
错误处理:
- 400: 文件不是有效的 UTF-8 文本
- 404: 文件不存在
- 409: base_hash 不匹配(并发冲突)
- 422: 无效的 patch 格式或 patch 应用失败
"""
file_obj = await Object.get(
session,
(Object.id == file_id) & (Object.deleted_at == None)
)
if not file_obj or file_obj.owner_id != user.id:
http_exceptions.raise_not_found("文件不存在")
if not file_obj.is_file:
http_exceptions.raise_bad_request("对象不是文件")
if file_obj.is_banned:
http_exceptions.raise_banned()
physical_file = await file_obj.awaitable_attrs.physical_file
if not physical_file or not physical_file.storage_path:
http_exceptions.raise_internal_error("文件存储路径丢失")
storage_path = physical_file.storage_path
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
if not policy:
http_exceptions.raise_internal_error("存储策略不存在")
if policy.type != PolicyType.LOCAL:
http_exceptions.raise_not_implemented("S3 存储暂未实现")
storage_service = LocalStorageService(policy)
raw_bytes = await storage_service.read_file(storage_path)
# 解码 + 规范化
original_text = raw_bytes.decode('utf-8')
original_text = original_text.replace('\r\n', '\n').replace('\r', '\n')
normalized_bytes = original_text.encode('utf-8')
# 冲突检测hash 基于规范化后的内容,与 GET 端点一致)
current_hash = hashlib.sha256(normalized_bytes).hexdigest()
if current_hash != request.base_hash:
http_exceptions.raise_conflict("文件内容已被修改,请刷新后重试")
# 解析并应用 patch
diffs = list(whatthepatch.parse_patch(request.patch))
if not diffs:
http_exceptions.raise_unprocessable_entity("无效的 patch 格式")
try:
result = whatthepatch.apply_diff(diffs[0], original_text)
except HunkApplyException:
http_exceptions.raise_unprocessable_entity("Patch 应用失败,差异内容与当前文件不匹配")
new_text = '\n'.join(result)
# 保持尾部换行符一致
if original_text.endswith('\n') and not new_text.endswith('\n'):
new_text += '\n'
new_bytes = new_text.encode('utf-8')
# 写入文件
await storage_service.write_file(storage_path, new_bytes)
# 更新数据库
owner_id = file_obj.owner_id
old_size = file_obj.size
new_size = len(new_bytes)
size_diff = new_size - old_size
file_obj.size = new_size
file_obj = await file_obj.save(session, commit=False)
physical_file.size = new_size
physical_file = await physical_file.save(session, commit=False)
if size_diff != 0:
await adjust_user_storage(session, owner_id, size_diff, commit=False)
await session.commit()
new_hash = hashlib.sha256(new_bytes).hexdigest()
l.info(f"文本文件增量保存: file_id={file_id}, size={old_size}->{new_size}")
return PatchContentResponse(new_hash=new_hash, new_size=new_size)
@router.get(

View File

@@ -0,0 +1,106 @@
"""
文件查看器查询端点
提供按文件扩展名查询可用查看器的功能,包含用户组访问控制过滤。
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_
from sqlalchemy import select
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import (
FileApp,
FileAppExtension,
FileAppGroupLink,
FileAppSummary,
FileViewersResponse,
User,
UserFileAppDefault,
)
viewers_router = APIRouter(prefix="/viewers", tags=["file", "viewers"])
@viewers_router.get(
path='',
summary='查询可用文件查看器',
description='根据文件扩展名查询可用的查看器应用列表。',
)
async def get_viewers(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
ext: Annotated[str, Query(max_length=20, description="文件扩展名")],
) -> FileViewersResponse:
"""
查询可用文件查看器端点
流程:
1. 规范化扩展名(小写,去点号)
2. 查询匹配的已启用应用
3. 按用户组权限过滤
4. 按 priority 排序
5. 查询用户默认偏好
认证JWT token 必填
错误处理:
- 401: 未授权
"""
# 规范化扩展名
normalized_ext = ext.lower().strip().lstrip('.')
# 查询匹配扩展名的应用(已启用的)
ext_records: list[FileAppExtension] = await FileAppExtension.get(
session,
and_(
FileAppExtension.extension == normalized_ext,
),
fetch_mode="all",
load=FileAppExtension.app,
)
# 过滤和收集可用应用
user_group_id = user.group_id
viewers: list[tuple[FileAppSummary, int]] = []
for ext_record in ext_records:
app: FileApp = ext_record.app
if not app.is_enabled:
continue
if app.is_restricted:
# 检查用户组权限FileAppGroupLink 是纯关联表,使用 session 查询)
stmt = select(FileAppGroupLink).where(
and_(
FileAppGroupLink.app_id == app.id,
FileAppGroupLink.group_id == user_group_id,
)
)
result = await session.exec(stmt)
group_link = result.first()
if not group_link:
continue
viewers.append((app.to_summary(), ext_record.priority))
# 按 priority 排序
viewers.sort(key=lambda x: x[1])
# 查询用户默认偏好
user_default: UserFileAppDefault | None = await UserFileAppDefault.get(
session,
and_(
UserFileAppDefault.user_id == user.id,
UserFileAppDefault.extension == normalized_ext,
),
)
return FileViewersResponse(
viewers=[v[0] for v in viewers],
default_viewer_id=user_default.app_id if user_default else None,
)

View File

@@ -14,7 +14,7 @@ from sqlmodels.share import (
ShareDetailResponse, ShareOwnerInfo, ShareObjectItem,
)
from sqlmodels.object import Object, ObjectType
from sqlmodels.mixin import ListResponse, TableViewRequest
from sqlmodel_ext import ListResponse, TableViewRequest
from utils import http_exceptions
from utils.password.pwd import Password, PasswordStatus

View File

@@ -17,12 +17,14 @@ from sqlmodels.color import ThemeColorsBase
from sqlmodels.user_authn import UserAuthn
from utils import JWT, Password, http_exceptions
from utils.password.pwd import PasswordStatus, TwoFactorResponse, TwoFactorVerifyRequest
from .file_viewers import file_viewers_router
user_settings_router = APIRouter(
prefix='/settings',
tags=["user", "user_settings"],
dependencies=[Depends(auth_required)],
)
user_settings_router.include_router(file_viewers_router)
@user_settings_router.get(

View File

@@ -0,0 +1,150 @@
"""
用户文件查看器偏好设置端点
提供用户"始终使用"默认查看器的增删查功能。
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy import and_
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import (
FileApp,
FileAppExtension,
SetDefaultViewerRequest,
User,
UserFileAppDefault,
UserFileAppDefaultResponse,
)
from utils import http_exceptions
file_viewers_router = APIRouter(
prefix='/file-viewers',
tags=["user", "user_settings", "file_viewers"],
dependencies=[Depends(auth_required)],
)
@file_viewers_router.put(
path='/default',
summary='设置默认查看器',
description='为指定扩展名设置"始终使用"的查看器。',
)
async def set_default_viewer(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: SetDefaultViewerRequest,
) -> UserFileAppDefaultResponse:
"""
设置默认查看器端点
如果用户已有该扩展名的默认设置,则更新;否则创建新记录。
认证JWT token 必填
错误处理:
- 404: 应用不存在
- 400: 应用不支持该扩展名
"""
# 规范化扩展名
normalized_ext = request.extension.lower().strip().lstrip('.')
# 验证应用存在
app: FileApp | None = await FileApp.get(session, FileApp.id == request.app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 验证应用支持该扩展名
ext_record: FileAppExtension | None = await FileAppExtension.get(
session,
and_(
FileAppExtension.app_id == app.id,
FileAppExtension.extension == normalized_ext,
),
)
if not ext_record:
http_exceptions.raise_bad_request("该应用不支持此扩展名")
# 查找已有记录
existing: UserFileAppDefault | None = await UserFileAppDefault.get(
session,
and_(
UserFileAppDefault.user_id == user.id,
UserFileAppDefault.extension == normalized_ext,
),
)
if existing:
existing.app_id = request.app_id
existing = await existing.save(session)
# 重新加载 app 关系
await session.refresh(existing, attribute_names=["app"])
return existing.to_response()
else:
new_default = UserFileAppDefault(
user_id=user.id,
extension=normalized_ext,
app_id=request.app_id,
)
new_default = await new_default.save(session)
# 重新加载 app 关系
await session.refresh(new_default, attribute_names=["app"])
return new_default.to_response()
@file_viewers_router.get(
path='/defaults',
summary='列出所有默认查看器设置',
description='获取当前用户所有"始终使用"的查看器偏好。',
)
async def list_default_viewers(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
) -> list[UserFileAppDefaultResponse]:
"""
列出所有默认查看器设置端点
认证JWT token 必填
"""
defaults: list[UserFileAppDefault] = await UserFileAppDefault.get(
session,
UserFileAppDefault.user_id == user.id,
fetch_mode="all",
load=UserFileAppDefault.app,
)
return [d.to_response() for d in defaults]
@file_viewers_router.delete(
path='/default/{default_id}',
summary='撤销默认查看器设置',
description='删除指定的"始终使用"偏好。',
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_default_viewer(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
default_id: UUID,
) -> None:
"""
撤销默认查看器设置端点
认证JWT token 必填
错误处理:
- 404: 记录不存在或不属于当前用户
"""
existing: UserFileAppDefault | None = await UserFileAppDefault.get(
session,
and_(
UserFileAppDefault.id == default_id,
UserFileAppDefault.user_id == user.id,
),
)
if not existing:
http_exceptions.raise_not_found("默认设置不存在")
await UserFileAppDefault.delete(session, existing)

11
routers/wopi/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
WOPIWeb Application Open Platform Interface路由
挂载在根级别 /wopi非 /api/v1 下),因为 WOPI 客户端要求标准路径。
"""
from fastapi import APIRouter
from .files import wopi_files_router
wopi_router = APIRouter(prefix="/wopi", tags=["wopi"])
wopi_router.include_router(wopi_files_router)

View File

@@ -0,0 +1,203 @@
"""
WOPI 文件操作端点
实现 WOPI 协议的核心文件操作接口:
- CheckFileInfo: 获取文件元数据
- GetFile: 下载文件内容
- PutFile: 上传/更新文件内容
"""
from uuid import UUID
from fastapi import APIRouter, Query, Request, Response
from fastapi.responses import JSONResponse
from loguru import logger as l
from middleware.dependencies import SessionDep
from sqlmodels import Object, PhysicalFile, Policy, PolicyType, User, WopiFileInfo
from service.storage import LocalStorageService
from utils import http_exceptions
from utils.JWT.wopi_token import verify_wopi_token
wopi_files_router = APIRouter(prefix="/files", tags=["wopi"])
@wopi_files_router.get(
path='/{file_id}',
summary='WOPI CheckFileInfo',
description='返回文件的元数据信息。',
)
async def check_file_info(
session: SessionDep,
file_id: UUID,
access_token: str = Query(...),
) -> JSONResponse:
"""
WOPI CheckFileInfo 端点
认证WOPI access_tokenquery 参数)
返回 WOPI 规范的 PascalCase JSON。
"""
# 验证令牌
payload = verify_wopi_token(access_token)
if not payload or payload.file_id != file_id:
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
# 获取文件
file_obj: Object | None = await Object.get(
session,
Object.id == file_id,
)
if not file_obj or not file_obj.is_file:
http_exceptions.raise_not_found("文件不存在")
# 获取用户信息
user: User | None = await User.get(session, User.id == payload.user_id)
user_name = user.nickname or user.email or str(payload.user_id) if user else str(payload.user_id)
# 构建响应
info = WopiFileInfo(
base_file_name=file_obj.name,
size=file_obj.size or 0,
owner_id=str(file_obj.owner_id),
user_id=str(payload.user_id),
user_friendly_name=user_name,
version=file_obj.updated_at.isoformat() if file_obj.updated_at else "",
user_can_write=payload.can_write,
read_only=not payload.can_write,
supports_update=payload.can_write,
)
return JSONResponse(content=info.to_wopi_dict())
@wopi_files_router.get(
path='/{file_id}/contents',
summary='WOPI GetFile',
description='返回文件的二进制内容。',
)
async def get_file(
session: SessionDep,
file_id: UUID,
access_token: str = Query(...),
) -> Response:
"""
WOPI GetFile 端点
认证WOPI access_tokenquery 参数)
返回文件的原始二进制内容。
"""
# 验证令牌
payload = verify_wopi_token(access_token)
if not payload or payload.file_id != file_id:
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
# 获取文件
file_obj: Object | None = await Object.get(session, Object.id == file_id)
if not file_obj or not file_obj.is_file:
http_exceptions.raise_not_found("文件不存在")
# 获取物理文件
physical_file: PhysicalFile | None = await file_obj.awaitable_attrs.physical_file
if not physical_file or not physical_file.storage_path:
http_exceptions.raise_internal_error("文件存储路径丢失")
# 获取策略
policy: Policy | None = await Policy.get(session, Policy.id == file_obj.policy_id)
if not policy:
http_exceptions.raise_internal_error("存储策略不存在")
if policy.type == PolicyType.LOCAL:
storage_service = LocalStorageService(policy)
if not await storage_service.file_exists(physical_file.storage_path):
http_exceptions.raise_not_found("物理文件不存在")
import aiofiles
async with aiofiles.open(physical_file.storage_path, 'rb') as f:
content = await f.read()
return Response(
content=content,
media_type="application/octet-stream",
headers={"X-WOPI-ItemVersion": file_obj.updated_at.isoformat() if file_obj.updated_at else ""},
)
else:
http_exceptions.raise_not_implemented("S3 存储暂未实现")
@wopi_files_router.post(
path='/{file_id}/contents',
summary='WOPI PutFile',
description='更新文件内容。',
)
async def put_file(
session: SessionDep,
request: Request,
file_id: UUID,
access_token: str = Query(...),
) -> JSONResponse:
"""
WOPI PutFile 端点
认证WOPI access_tokenquery 参数,需要写权限)
接收请求体中的文件二进制内容并覆盖存储。
"""
# 验证令牌
payload = verify_wopi_token(access_token)
if not payload or payload.file_id != file_id:
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
if not payload.can_write:
http_exceptions.raise_forbidden("没有写入权限")
# 获取文件
file_obj: Object | None = await Object.get(session, Object.id == file_id)
if not file_obj or not file_obj.is_file:
http_exceptions.raise_not_found("文件不存在")
# 获取物理文件
physical_file: PhysicalFile | None = await file_obj.awaitable_attrs.physical_file
if not physical_file or not physical_file.storage_path:
http_exceptions.raise_internal_error("文件存储路径丢失")
# 获取策略
policy: Policy | None = await Policy.get(session, Policy.id == file_obj.policy_id)
if not policy:
http_exceptions.raise_internal_error("存储策略不存在")
# 读取请求体
content = await request.body()
if policy.type == PolicyType.LOCAL:
import aiofiles
async with aiofiles.open(physical_file.storage_path, 'wb') as f:
await f.write(content)
# 更新文件大小
new_size = len(content)
old_size = file_obj.size or 0
file_obj.size = new_size
file_obj = await file_obj.save(session, commit=False)
# 更新物理文件大小
physical_file.size = new_size
await physical_file.save(session, commit=False)
# 更新用户存储配额
size_diff = new_size - old_size
if size_diff != 0:
from service.storage import adjust_user_storage
await adjust_user_storage(session, file_obj.owner_id, size_diff, commit=False)
await session.commit()
l.info(f"WOPI PutFile: file_id={file_id}, new_size={new_size}")
return JSONResponse(
content={"ItemVersion": file_obj.updated_at.isoformat() if file_obj.updated_at else ""},
status_code=200,
)
else:
http_exceptions.raise_not_implemented("S3 存储暂未实现")