Files
disknext/docs/file-viewer-api.md
于小丘 eac0766e79 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>
2026-02-14 14:23:17 +08:00

16 KiB
Raw Permalink Blame History

文件预览应用选择器 — 前端适配文档

概述

文件预览系统类似 Android 的"使用什么应用打开"机制:用户点击文件时,前端根据扩展名查询可用查看器列表,展示选择弹窗,用户可选"仅此一次"或"始终使用"。

应用类型

type 说明 前端处理方式
builtin 前端内置组件 根据 app_key 路由到内置组件(如 pdfjsmonaco
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_vieweriframecollaborawopionlyofficewopi默认禁用需管理员在后台启用和配置。


文件下载 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。目前建议

  1. 优先使用 WOPI 类型Collabora/OnlyOffice不存在此限制
  2. Office Online 预览在文件较小时通常只拉取一次,大多数场景可用
  3. 如需稳定方案,可等待外链端点实现后再启用 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_restrictedtrue 时,只有 allowed_group_ids 中的用户组成员能看到此应用。is_restrictedfalse 时所有用户可见,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
}