- 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>
16 KiB
文件预览应用选择器 — 前端适配文档
概述
文件预览系统类似 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 阅读器 | |
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 获取文件内容即可:
// 方式 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。
当前可行方案:
// 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
// <iframe src={iframeSrc} />
已知限制:下载令牌为一次性使用。如果第三方服务多次拉取文件(如 Office Online 可能重试), 第二次请求会 404。后续版本将实现
/file/get/{id}/{name}外链端点(多次可用),届时 iframe 应改用外链 URL。目前建议:
- 优先使用 WOPI 类型(Collabora/OnlyOffice),不存在此限制
- Office Online 预览在文件较小时通常只拉取一次,大多数场景可用
- 如需稳定方案,可等待外链端点实现后再启用 iframe 类型应用
wopi 类型 — 无需关心文件 URL
WOPI 类型的查看器完全由后端处理文件传输,前端只需:
// 1. 创建 WOPI 会话
const session = await api.post(`/file/${fileId}/wopi-session`)
// 2. 直接嵌入编辑器
// <iframe src={session.editor_url} />
编辑器(Collabora/OnlyOffice)会通过 WOPI 协议自动从 /wopi/files/{id}/contents 获取文件内容,使用 access_token 认证,前端无需干预。
用户端 API
1. 查询可用查看器
用户点击文件时调用,获取该扩展名的可用查看器列表。
GET /api/v1/file/viewers?ext={extension}
Authorization: Bearer {token}
Query 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| ext | string | 是 | 文件扩展名,最长 20 字符。支持带点号(.pdf)、大写(PDF),后端会自动规范化 |
响应 200
{
"viewers": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "PDF 阅读器",
"app_key": "pdfjs",
"type": "builtin",
"icon": "file-pdf",
"description": "基于 pdf.js 的 PDF 在线阅读器",
"iframe_url_template": null,
"wopi_editor_url_template": null
}
],
"default_viewer_id": null
}
字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| viewers | FileAppSummary[] | 可用查看器列表,已按优先级排序 |
| default_viewer_id | string | null | 用户设置的"始终使用"查看器 UUID,未设置则为 null |
FileAppSummary
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 应用 UUID |
| name | string | 应用显示名称 |
| app_key | string | 应用唯一标识,前端路由用 |
| type | "builtin" | "iframe" | "wopi" |
应用类型 |
| icon | string | null | 图标名称(可映射到 icon library) |
| description | string | null | 应用描述 |
| iframe_url_template | string | null | iframe 类型专用,URL 模板含 {file_url} 占位符 |
| wopi_editor_url_template | string | null | wopi 类型专用,编辑器 URL 模板 |
2. 设置默认查看器("始终使用")
用户在选择弹窗中勾选"始终使用此应用"时调用。
PUT /api/v1/user/settings/file-viewers/default
Authorization: Bearer {token}
Content-Type: application/json
请求体
{
"extension": "pdf",
"app_id": "550e8400-e29b-41d4-a716-446655440000"
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| extension | string | 是 | 文件扩展名(小写,无点号) |
| app_id | UUID | 是 | 选择的查看器应用 UUID |
响应 200
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"extension": "pdf",
"app": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "PDF 阅读器",
"app_key": "pdfjs",
"type": "builtin",
"icon": "file-pdf",
"description": "基于 pdf.js 的 PDF 在线阅读器",
"iframe_url_template": null,
"wopi_editor_url_template": null
}
}
错误码
| 状态码 | 说明 |
|---|---|
| 400 | 该应用不支持此扩展名 |
| 404 | 应用不存在 |
同一扩展名只允许一个默认值。重复 PUT 同一 extension 会更新(upsert),不会冲突。
3. 列出所有默认查看器设置
用于用户设置页展示"已设为始终使用"的列表。
GET /api/v1/user/settings/file-viewers/defaults
Authorization: Bearer {token}
响应 200
[
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"extension": "pdf",
"app": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "PDF 阅读器",
"app_key": "pdfjs",
"type": "builtin",
"icon": "file-pdf",
"description": null,
"iframe_url_template": null,
"wopi_editor_url_template": null
}
}
]
4. 撤销默认查看器设置
用户在设置页点击"取消始终使用"时调用。
DELETE /api/v1/user/settings/file-viewers/default/{id}
Authorization: Bearer {token}
响应 204 No Content
错误码
| 状态码 | 说明 |
|---|---|
| 404 | 记录不存在或不属于当前用户 |
5. 创建 WOPI 会话
打开 WOPI 类型应用(如 Collabora、OnlyOffice)时调用。
POST /api/v1/file/{file_id}/wopi-session
Authorization: Bearer {token}
响应 200
{
"wopi_src": "http://localhost:8000/wopi/files/770e8400-e29b-41d4-a716-446655440002",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_token_ttl": 1739577600000,
"editor_url": "http://collabora:9980/loleaflet/dist/loleaflet.html?WOPISrc=...&access_token=...&access_token_ttl=..."
}
| 字段 | 类型 | 说明 |
|---|---|---|
| wopi_src | string | WOPI 源 URL(传给编辑器) |
| access_token | string | WOPI 访问令牌 |
| access_token_ttl | int | 令牌过期毫秒时间戳 |
| editor_url | string | 完整的编辑器 URL,直接嵌入 iframe 即可 |
错误码
| 状态码 | 说明 |
|---|---|
| 400 | 文件无扩展名 / WOPI 应用未配置编辑器 URL |
| 403 | 用户组无权限 |
| 404 | 文件不存在 / 无可用 WOPI 查看器 |
前端交互流程
打开文件预览
用户点击文件
│
▼
GET /file/viewers?ext={扩展名}
│
├── viewers 为空 → 提示"暂无可用的预览方式"
│
├── default_viewer_id 不为空 → 直接用对应 viewer 打开(跳过选择弹窗)
│
└── viewers.length == 1 → 直接用唯一 viewer 打开(可选策略)
│
└── viewers.length > 1 → 展示选择弹窗
│
├── 用户选择 + 不勾选"始终使用" → 仅此一次打开
│
└── 用户选择 + 勾选"始终使用" → PUT /user/settings/file-viewers/default
│
└── 然后打开
根据 type 打开查看器
获取到 viewer 对象
│
├── type == "builtin"
│ └── 根据 app_key 路由到内置组件
│ switch(app_key):
│ "pdfjs" → <PdfViewer />
│ "monaco" → <CodeEditor />
│ "markdown" → <MarkdownPreview />
│ "image_viewer" → <ImageViewer />
│ "video_player" → <VideoPlayer />
│ "audio_player" → <AudioPlayer />
│
│ 获取文件内容:
│ POST /file/download/{file_id} → { access_token }
│ fileUrl = `${siteURL}/api/v1/file/download/${access_token}`
│ → 传 URL 或 fetch Blob 给内置组件
│
├── type == "iframe"
│ └── 1. POST /file/download/{file_id} → { access_token }
│ 2. fileUrl = `${siteURL}/api/v1/file/download/${access_token}`
│ 3. iframeSrc = viewer.iframe_url_template
│ .replace("{file_url}", encodeURIComponent(fileUrl))
│ 4. <iframe src={iframeSrc} />
│
└── type == "wopi"
└── 1. POST /file/{file_id}/wopi-session → { editor_url }
2. <iframe src={editor_url} />
(编辑器自动通过 WOPI 协议获取文件,前端无需处理)
管理员 API
所有管理端点需要管理员身份(JWT 中 group.admin == true)。
1. 列出所有文件应用
GET /api/v1/admin/file-app/list?page=1&page_size=20
Authorization: Bearer {admin_token}
响应 200
{
"apps": [
{
"id": "...",
"name": "PDF 阅读器",
"app_key": "pdfjs",
"type": "builtin",
"icon": "file-pdf",
"description": "...",
"is_enabled": true,
"is_restricted": false,
"iframe_url_template": null,
"wopi_discovery_url": null,
"wopi_editor_url_template": null,
"extensions": ["pdf"],
"allowed_group_ids": []
}
],
"total": 9
}
2. 创建文件应用
POST /api/v1/admin/file-app/
Authorization: Bearer {admin_token}
Content-Type: application/json
{
"name": "自定义查看器",
"app_key": "my_viewer",
"type": "iframe",
"description": "自定义 iframe 查看器",
"is_enabled": true,
"is_restricted": false,
"iframe_url_template": "https://example.com/view?url={file_url}",
"extensions": ["pdf", "docx"],
"allowed_group_ids": []
}
响应 201 — 返回 FileAppResponse(同列表中的单项)
错误码: 409 — app_key 已存在
3. 获取应用详情
GET /api/v1/admin/file-app/{id}
响应 200 — FileAppResponse
4. 更新应用
PATCH /api/v1/admin/file-app/{id}
只传需要更新的字段:
{
"name": "新名称",
"is_enabled": false
}
响应 200 — FileAppResponse
5. 删除应用
DELETE /api/v1/admin/file-app/{id}
响应 204 No Content(级联删除扩展名关联、用户偏好、用户组关联)
6. 全量替换扩展名列表
PUT /api/v1/admin/file-app/{id}/extensions
{
"extensions": ["doc", "docx", "odt"]
}
响应 200 — FileAppResponse
7. 全量替换允许的用户组
PUT /api/v1/admin/file-app/{id}/groups
{
"group_ids": ["uuid-1", "uuid-2"]
}
响应 200 — FileAppResponse
is_restricted为true时,只有allowed_group_ids中的用户组成员能看到此应用。is_restricted为false时所有用户可见,allowed_group_ids不生效。
TypeScript 类型参考
type FileAppType = 'builtin' | 'iframe' | 'wopi'
interface FileAppSummary {
id: string
name: string
app_key: string
type: FileAppType
icon: string | null
description: string | null
iframe_url_template: string | null
wopi_editor_url_template: string | null
}
interface FileViewersResponse {
viewers: FileAppSummary[]
default_viewer_id: string | null
}
interface SetDefaultViewerRequest {
extension: string
app_id: string
}
interface UserFileAppDefaultResponse {
id: string
extension: string
app: FileAppSummary
}
interface WopiSessionResponse {
wopi_src: string
access_token: string
access_token_ttl: number
editor_url: string
}
// ========== 管理员类型 ==========
interface FileAppResponse {
id: string
name: string
app_key: string
type: FileAppType
icon: string | null
description: string | null
is_enabled: boolean
is_restricted: boolean
iframe_url_template: string | null
wopi_discovery_url: string | null
wopi_editor_url_template: string | null
extensions: string[]
allowed_group_ids: string[]
}
interface FileAppListResponse {
apps: FileAppResponse[]
total: number
}
interface FileAppCreateRequest {
name: string
app_key: string
type: FileAppType
icon?: string
description?: string
is_enabled?: boolean // default: true
is_restricted?: boolean // default: false
iframe_url_template?: string
wopi_discovery_url?: string
wopi_editor_url_template?: string
extensions?: string[] // default: []
allowed_group_ids?: string[] // default: []
}
interface FileAppUpdateRequest {
name?: string
app_key?: string
type?: FileAppType
icon?: string
description?: string
is_enabled?: boolean
is_restricted?: boolean
iframe_url_template?: string
wopi_discovery_url?: string
wopi_editor_url_template?: string
}