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 +//