From eac0766e794c5241503dbe9c3999bec62583ca40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Sat, 14 Feb 2026 14:23:17 +0800 Subject: [PATCH] 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 --- .dockerignore | 37 + .github/copilot-instructions.md | 14 +- .gitignore | 10 + AGENTS.md | 14 +- CLAUDE.md | 14 +- Dockerfile | 47 +- docs/file-viewer-api.md | 594 ++++++++ docs/text-editor-api.md | 242 ++++ license_public.pem | 14 + main.py | 61 +- middleware/dependencies.py | 2 +- pyproject.toml | 9 + routers/__init__.py | 4 +- routers/api/v1/admin/__init__.py | 4 +- routers/api/v1/admin/file_app/__init__.py | 348 +++++ routers/api/v1/admin/policy/__init__.py | 2 +- routers/api/v1/file/__init__.py | 355 ++++- routers/api/v1/file/viewers/__init__.py | 106 ++ routers/api/v1/share/__init__.py | 2 +- routers/api/v1/user/settings/__init__.py | 2 + .../v1/user/settings/file_viewers/__init__.py | 150 ++ routers/wopi/__init__.py | 11 + routers/wopi/files/__init__.py | 203 +++ service/storage/naming_rule.py | 2 +- setup_cython.py | 91 ++ sqlmodels/__init__.py | 12 +- sqlmodels/auth_identity.py | 3 +- sqlmodels/base/README.md | 657 --------- sqlmodels/base/__init__.py | 12 - sqlmodels/base/sqlmodel_base.py | 846 ----------- sqlmodels/color.py | 2 +- sqlmodels/database.py | 33 - sqlmodels/download.py | 3 +- sqlmodels/file_app.py | 409 ++++++ sqlmodels/group.py | 3 +- sqlmodels/migration.py | 144 ++ sqlmodels/mixin/README.md | 543 ------- sqlmodels/mixin/__init__.py | 62 - sqlmodels/mixin/info_response.py | 46 - sqlmodels/mixin/optimistic_lock.py | 90 -- sqlmodels/mixin/polymorphic.py | 710 ---------- sqlmodels/mixin/relation_preload.py | 470 ------- sqlmodels/mixin/table.py | 1247 ----------------- sqlmodels/model_base.py | 2 +- sqlmodels/node.py | 3 +- sqlmodels/object.py | 3 +- sqlmodels/order.py | 3 +- sqlmodels/physical_file.py | 3 +- sqlmodels/policy.py | 3 +- sqlmodels/redeem.py | 3 +- sqlmodels/report.py | 3 +- sqlmodels/setting.py | 4 +- sqlmodels/share.py | 4 +- sqlmodels/source_link.py | 3 +- sqlmodels/storage_pack.py | 3 +- sqlmodels/tag.py | 3 +- sqlmodels/task.py | 3 +- sqlmodels/theme_preset.py | 4 +- sqlmodels/uri.py | 2 +- sqlmodels/user.py | 4 +- sqlmodels/user_authn.py | 3 +- sqlmodels/webdav.py | 3 +- sqlmodels/wopi.py | 83 ++ tests/integration/api/test_admin_file_app.py | 253 ++++ tests/integration/api/test_file_content.py | 466 ++++++ tests/integration/api/test_file_viewers.py | 305 ++++ tests/unit/models/test_file_app.py | 386 +++++ tests/unit/models/test_setting.py | 2 +- tests/unit/utils/test_patch.py | 178 +++ tests/unit/utils/test_wopi_token.py | 77 + utils/JWT/__init__.py | 4 +- utils/JWT/wopi_token.py | 67 + utils/http/http_exceptions.py | 4 + uv.lock | 142 ++ 74 files changed, 4819 insertions(+), 4837 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/file-viewer-api.md create mode 100644 docs/text-editor-api.md create mode 100644 license_public.pem create mode 100644 routers/api/v1/admin/file_app/__init__.py create mode 100644 routers/api/v1/file/viewers/__init__.py create mode 100644 routers/api/v1/user/settings/file_viewers/__init__.py create mode 100644 routers/wopi/__init__.py create mode 100644 routers/wopi/files/__init__.py create mode 100644 setup_cython.py delete mode 100644 sqlmodels/base/README.md delete mode 100644 sqlmodels/base/__init__.py delete mode 100644 sqlmodels/base/sqlmodel_base.py delete mode 100644 sqlmodels/database.py create mode 100644 sqlmodels/file_app.py delete mode 100644 sqlmodels/mixin/README.md delete mode 100644 sqlmodels/mixin/__init__.py delete mode 100644 sqlmodels/mixin/info_response.py delete mode 100644 sqlmodels/mixin/optimistic_lock.py delete mode 100644 sqlmodels/mixin/polymorphic.py delete mode 100644 sqlmodels/mixin/relation_preload.py delete mode 100644 sqlmodels/mixin/table.py create mode 100644 sqlmodels/wopi.py create mode 100644 tests/integration/api/test_admin_file_app.py create mode 100644 tests/integration/api/test_file_content.py create mode 100644 tests/integration/api/test_file_viewers.py create mode 100644 tests/unit/models/test_file_app.py create mode 100644 tests/unit/utils/test_patch.py create mode 100644 tests/unit/utils/test_wopi_token.py create mode 100644 utils/JWT/wopi_token.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0282a6b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +.git/ +.gitignore +.github/ +.idea/ +.vscode/ +.venv/ +.env +.env.* +.run/ +.claude/ + +__pycache__/ +*.py[cod] + +tests/ +htmlcov/ +.pytest_cache/ +.coverage +coverage.xml + +*.db +*.sqlite +*.sqlite3 +*.log +logs/ +data/ + +Dockerfile +.dockerignore + +# Cython 编译产物 +*.c +build/ + +# 许可证私钥和工具脚本 +license_private.pem +scripts/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e961ba8..10fc525 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -449,13 +449,13 @@ return device # 此时device已过期 ```python import asyncio from sqlmodel import Field -from sqlmodels.base import UUIDTableBase, SQLModelBase +from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin class CharacterBase(SQLModelBase): name: str """角色名称""" -class Character(CharacterBase, UUIDTableBase): +class Character(CharacterBase, UUIDTableBaseMixin): """充血模型:包含数据和业务逻辑""" # ==================== 运行时属性(在model_post_init初始化) ==================== @@ -570,11 +570,11 @@ async with character.init(session): from abc import ABC, abstractmethod from uuid import UUID from sqlmodel import Field -from sqlmodels.base import ( +from sqlmodel_ext import ( SQLModelBase, - UUIDTableBase, + UUIDTableBaseMixin, create_subclass_id_mixin, - AutoPolymorphicIdentityMixin + AutoPolymorphicIdentityMixin, ) # 1. 定义Base类(只有字段,无表) @@ -591,7 +591,7 @@ class ASRBase(SQLModelBase): # 2. 定义抽象父类(有表) class ASR( ASRBase, - UUIDTableBase, + UUIDTableBaseMixin, ABC, polymorphic_on='__polymorphic_name', polymorphic_abstract=True @@ -1148,7 +1148,7 @@ from sqlmodel import Field # 3. 本地应用导入(从项目根目录的包开始) from dependencies import SessionDep from sqlmodels.user import User -from sqlmodels.base import UUIDTableBase +from sqlmodel_ext import UUIDTableBaseMixin # 4. 相对导入(同包内的模块) from .base import BaseClass diff --git a/.gitignore b/.gitignore index 1acc9c6..20979a4 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,13 @@ data/ # JB 的运行配置(换设备用不了) .run/ .xml + +# 前端构建产物(Docker 构建时复制) +statics/ + +# Cython 编译产物 +*.c + +# 许可证密钥(保密) +license_private.pem +license.key diff --git a/AGENTS.md b/AGENTS.md index 0a3b586..c790885 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -449,13 +449,13 @@ return device # 此时device已过期 ```python import asyncio from sqlmodel import Field -from sqlmodels.base import UUIDTableBase, SQLModelBase +from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin class CharacterBase(SQLModelBase): name: str """角色名称""" -class Character(CharacterBase, UUIDTableBase): +class Character(CharacterBase, UUIDTableBaseMixin): """充血模型:包含数据和业务逻辑""" # ==================== 运行时属性(在model_post_init初始化) ==================== @@ -570,11 +570,11 @@ async with character.init(session): from abc import ABC, abstractmethod from uuid import UUID from sqlmodel import Field -from sqlmodels.base import ( +from sqlmodel_ext import ( SQLModelBase, - UUIDTableBase, + UUIDTableBaseMixin, create_subclass_id_mixin, - AutoPolymorphicIdentityMixin + AutoPolymorphicIdentityMixin, ) # 1. 定义Base类(只有字段,无表) @@ -591,7 +591,7 @@ class ASRBase(SQLModelBase): # 2. 定义抽象父类(有表) class ASR( ASRBase, - UUIDTableBase, + UUIDTableBaseMixin, ABC, polymorphic_on='__polymorphic_name', polymorphic_abstract=True @@ -1148,7 +1148,7 @@ from sqlmodel import Field # 3. 本地应用导入(从项目根目录的包开始) from dependencies import SessionDep from sqlmodels.user import User -from sqlmodels.base import UUIDTableBase +from sqlmodel_ext import UUIDTableBaseMixin # 4. 相对导入(同包内的模块) from .base import BaseClass diff --git a/CLAUDE.md b/CLAUDE.md index 0a3b586..c790885 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -449,13 +449,13 @@ return device # 此时device已过期 ```python import asyncio from sqlmodel import Field -from sqlmodels.base import UUIDTableBase, SQLModelBase +from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin class CharacterBase(SQLModelBase): name: str """角色名称""" -class Character(CharacterBase, UUIDTableBase): +class Character(CharacterBase, UUIDTableBaseMixin): """充血模型:包含数据和业务逻辑""" # ==================== 运行时属性(在model_post_init初始化) ==================== @@ -570,11 +570,11 @@ async with character.init(session): from abc import ABC, abstractmethod from uuid import UUID from sqlmodel import Field -from sqlmodels.base import ( +from sqlmodel_ext import ( SQLModelBase, - UUIDTableBase, + UUIDTableBaseMixin, create_subclass_id_mixin, - AutoPolymorphicIdentityMixin + AutoPolymorphicIdentityMixin, ) # 1. 定义Base类(只有字段,无表) @@ -591,7 +591,7 @@ class ASRBase(SQLModelBase): # 2. 定义抽象父类(有表) class ASR( ASRBase, - UUIDTableBase, + UUIDTableBaseMixin, ABC, polymorphic_on='__polymorphic_name', polymorphic_abstract=True @@ -1148,7 +1148,7 @@ from sqlmodel import Field # 3. 本地应用导入(从项目根目录的包开始) from dependencies import SessionDep from sqlmodels.user import User -from sqlmodels.base import UUIDTableBase +from sqlmodel_ext import UUIDTableBaseMixin # 4. 相对导入(同包内的模块) from .base import BaseClass diff --git a/Dockerfile b/Dockerfile index af6ff71..30a2e9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,52 @@ -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim +# ============================================================ +# 基础层:安装运行时依赖 +# ============================================================ +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS base WORKDIR /app COPY pyproject.toml uv.lock ./ - RUN uv sync --frozen --no-dev COPY . . -EXPOSE 5213 +# ============================================================ +# Community 版本:删除 ee/ 目录 +# ============================================================ +FROM base AS community -CMD ["uv", "run", "fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "5213"] \ No newline at end of file +RUN rm -rf ee/ +COPY statics/ /app/statics/ + +EXPOSE 5213 +CMD ["uv", "run", "fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "5213"] + +# ============================================================ +# Pro 编译层:Cython 编译 ee/ 模块 +# ============================================================ +FROM base AS pro-builder + +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN uv sync --frozen --no-dev --extra build + +RUN uv run python setup_cython.py build_ext --inplace && \ + uv run python setup_cython.py clean_artifacts + +# ============================================================ +# Pro 版本:包含编译后的 ee/ 模块(仅 __init__.py + .so) +# ============================================================ +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS pro + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY --from=pro-builder /app/ /app/ +COPY statics/ /app/statics/ + +EXPOSE 5213 +CMD ["uv", "run", "fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "5213"] diff --git a/docs/file-viewer-api.md b/docs/file-viewer-api.md new file mode 100644 index 0000000..240816e --- /dev/null +++ b/docs/file-viewer-api.md @@ -0,0 +1,594 @@ +# 文件预览应用选择器 — 前端适配文档 + +## 概述 + +文件预览系统类似 Android 的"使用什么应用打开"机制:用户点击文件时,前端根据扩展名查询可用查看器列表,展示选择弹窗,用户可选"仅此一次"或"始终使用"。 + +### 应用类型 + +| type | 说明 | 前端处理方式 | +|------|------|-------------| +| `builtin` | 前端内置组件 | 根据 `app_key` 路由到内置组件(如 `pdfjs`、`monaco`) | +| `iframe` | iframe 内嵌 | 将 `iframe_url_template` 中的 `{file_url}` 替换为文件下载 URL,嵌入 iframe | +| `wopi` | WOPI 协议 | 调用 `/file/{id}/wopi-session` 获取 `editor_url`,嵌入 iframe | + +### 内置 app_key 映射 + +前端需要为以下 `app_key` 实现对应的内置预览组件: + +| app_key | 组件 | 说明 | +|---------|------|------| +| `pdfjs` | PDF.js 阅读器 | pdf | +| `monaco` | Monaco Editor | txt, md, json, py, js, ts, html, css, ... | +| `markdown` | Markdown 渲染器 | md, markdown, mdx | +| `image_viewer` | 图片查看器 | jpg, png, gif, webp, svg, ... | +| `video_player` | HTML5 Video | mp4, webm, ogg, mov, mkv, m3u8 | +| `audio_player` | HTML5 Audio | mp3, wav, flac, aac, m4a, opus | + +> `office_viewer`(iframe)、`collabora`(wopi)、`onlyoffice`(wopi)默认禁用,需管理员在后台启用和配置。 + +--- + +## 文件下载 URL 与 iframe 预览 + +### 现有下载流程(两步式) + +``` +步骤1: POST /api/v1/file/download/{file_id} → { access_token, expires_in } +步骤2: GET /api/v1/file/download/{access_token} → 文件二进制流 +``` + +- 步骤 1 需要 JWT 认证,返回一个下载令牌(有效期 1 小时) +- 步骤 2 **不需要认证**,用令牌直接下载,**令牌为一次性**,下载后失效 + +### 各类型查看器获取文件内容的方式 + +| type | 获取文件方式 | 说明 | +|------|-------------|------| +| `builtin` | 前端自行获取 | 前端用 JS 调用下载接口拿到 Blob/ArrayBuffer,传给内置组件渲染 | +| `iframe` | 需要公开可访问的 URL | 第三方服务(如 Office Online)会**从服务端拉取文件** | +| `wopi` | WOPI 协议自动处理 | 编辑器通过 `/wopi/files/{id}/contents` 获取,前端只需嵌入 `editor_url` | + +### builtin 类型 — 前端自行获取 + +内置组件(pdfjs、monaco 等)运行在前端,直接用 JS 获取文件内容即可: + +```typescript +// 方式 A:用下载令牌拼 URL(适用于 PDF.js 等需要 URL 的组件) +const { access_token } = await api.post(`/file/download/${fileId}`) +const fileUrl = `${baseUrl}/api/v1/file/download/${access_token}` +// 传给 PDF.js: pdfjsLib.getDocument(fileUrl) + +// 方式 B:用 fetch + Authorization 头获取 Blob(适用于需要 ArrayBuffer 的组件) +const { access_token } = await api.post(`/file/download/${fileId}`) +const blob = await fetch(`${baseUrl}/api/v1/file/download/${access_token}`).then(r => r.blob()) +// 传给 Monaco: monaco.editor.create(el, { value: await blob.text() }) +``` + +### iframe 类型 — `{file_url}` 替换规则 + +`iframe_url_template` 中的 `{file_url}` 需要替换为一个**外部可访问的文件直链**。 + +**问题**:当前下载令牌是一次性的,而 Office Online 等服务可能多次请求该 URL。 + +**当前可行方案**: + +```typescript +// 1. 创建下载令牌 +const { access_token } = await api.post(`/file/download/${fileId}`) + +// 2. 拼出完整的文件 URL(必须是公网可达的地址) +const fileUrl = `${siteURL}/api/v1/file/download/${access_token}` + +// 3. 替换模板 +const iframeSrc = viewer.iframe_url_template.replace( + '{file_url}', + encodeURIComponent(fileUrl) +) + +// 4. 嵌入 iframe +//