Papermerge 3.5.3 API 使用详解

Papermerge 3.5.3 REST API 使用详解:token 认证、节点/上传/OCR/标签/检索/用户各域实测命令与原始回显、分页错误约定与十一个 API 坑

PapermergePapermerge文档管理2026/11/02

Papermerge 3.5.3 API 使用详解

目标:把 Papermerge 3.5.3 的 REST API 从头用到尾——拿 token、建目录/文档、上传、OCR、打标签、挂自定义字段、全文检索、用户管理,每个端点给出实测命令与原始回显,并沉淀 3.x API 的十余个行为坑。

本文实测环境为《Papermerge 3.5.3 Docker Compose 部署指南》部署的六服务栈(Web 入口 :8138);配置参数见《Papermerge 3.5.3 配置参数详解》;组件定位见《Papermerge 介绍与选型指南》。所有命令与回显均为 2026-11-02 实测照抄,示例账号 admin / q1w2e3r4。

为什么写 API 篇而不是 CLI 篇:3.x 已移除 2.x 的 papermanage 全家桶,镜像内也没有可用的管理 CLI(官方 token 文档推荐的 create_token.sh 实测已损坏,见第七章问题 3),REST API 是 3.5.3 唯一的编程入口。

一、环境信息

项目值
实例版本Papermerge 3.5.3(GET /api/version/ 实测回显)
API 基址http://127.0.0.1:8138/api(生产替换为服务器地址)
路径风格无版本段:/api/token、/api/nodes/……没有 /v1(见问题 1)
认证方式Authorization: Bearer <JWT>(由 POST /api/token 登录换取)
OpenAPI 规范GET /openapi.json 匿名可读,title "Papermerge DMS REST API" version 3.5.3,53 个路径
Swagger UIGET /api/docs(需 token,匿名 401)
测试凭据admin / q1w2e3r4(超级用户);quzuo / q1w2e3r4(普通用户,本文创建)
测试文件invoice-scan.pdf(81232 字节,德文扫描件,含词 KAFFEEMUEHLE)、notes-text.pdf(631 字节,文本层含 778811)

二、前置条件

  1. 六服务栈已部署且 GET /api/probe/ 返回 200(部署与验证见《Docker Compose 部署指南》)。
  2. 全文检索类端点要拿到命中结果,Solr core 必须已打 schema 补丁(copyField *_txt_en → _text_)——官方默认 configset 下检索恒 0 命中,修复过程见部署指南 4.1 与第九章问题 4。
  3. 备好 curl 与任一 JSON 解析工具(本文用 python3 提取字段)。
  4. 下文用 $TOKEN 表示 4.1 拿到的 access_token。

三、API 总览

3.1 规范与文档入口

实例自带 OpenAPI 规范且匿名可读(实测):

curl -s http://127.0.0.1:8138/openapi.json | python3 -c \
  "import json,sys;d=json.load(sys.stdin);print(d['info']['title'],d['info']['version'],len(d['paths']))"
Papermerge DMS REST API 3.5.3 53

交互式 swagger 在 /api/docs,但走 nginx 的 auth_request,匿名 401、带 token 200(实测):

curl -s -o /dev/null -w 'docs HTTP=%{http_code}\n' http://127.0.0.1:8138/api/docs
curl -s -o /dev/null -w 'docs HTTP=%{http_code}\n' http://127.0.0.1:8138/api/docs -H "Authorization: Bearer $TOKEN"
docs HTTP=401
docs HTTP=200

UI 侧入口为 User Menu → REST API(官方文档 rest-api/reference,查询日期 2026-11-01),背后就是这个 /api/docs。

3.2 网关路由(为什么路径是 /api/*)

webapp 容器内 nginx 实测配置(/etc/nginx/nginx.conf 节选):

location ~ ^/api(/?)(.*)  {
    auth_request /verify;
    proxy_pass http://127.0.0.1:8000/$2$is_args$args;
}
location /docs  { proxy_pass http://127.0.0.1:8000/docs; }
location /openapi.json  { proxy_pass http://127.0.0.1:8000/openapi.json; }
location = /verify { proxy_pass http://127.0.0.1:4010; }

即:/api/xxx 被反代为 Django 侧的 /xxx(/api 前缀在转发时被剥掉),每个 /api 请求先经内置 auth-server(:4010) 的 /verify 校验 Bearer token。这就是"路径没有 /v1"(openapi 里就没有)与"匿名一律 401"的机制根源。

3.3 端点域一览(53 路径按域归组)

域端点(省略 /api 前缀)本文实测
认证POST /token(auth-server 直供)✅
元信息GET /version/、GET /probe/、GET /docs、GET /openapi.json✅
节点POST/GET/DELETE /nodes/、GET /nodes/{parent_id}、PATCH /nodes/{node_id}、POST /nodes/move、GET /folders/{folder_id}✅
文档GET /documents/{id}、POST /documents/{id}/upload、GET /documents/{id}/versions、GET /documents/{id}/last-version/、PATCH /documents/{id}/type、GET+PATCH /documents/{id}/custom-fields、GET /documents/type/{document_type_id}✅(type/{id} 列表未深测)
版本GET /document-versions/{id}、GET+HEAD /document-versions/{id}/download、GET /document-versions/{id}/download-url✅
缩略图GET /thumbnails/{document_id}、GET /documents/thumbnail-img-status/✅(后者列表未深测)
OCR 任务POST /tasks/ocr✅
检索GET /search/✅
标签GET+POST /tags/、GET /tags/all、GET+PATCH+DELETE /tags/{tag_id}、GET+POST+PATCH+DELETE /nodes/{node_id}/tags✅
文档类型GET+POST /document-types/、GET /document-types/all、GET+PATCH+DELETE /document-types/{id}✅
自定义字段GET+POST /custom-fields/、GET /custom-fields/all、GET+PATCH+DELETE /custom-fields/{id}✅
用户GET /users/me、GET+POST /users/、GET /users/all、GET+PATCH+DELETE /users/{id}、POST /users/{id}/change-password、GET /users/group-homes、GET /users/group-inboxes✅(change-password 等列表未深测)
组/角色GET+POST /groups/、/groups/all、/groups/{id};/roles/ 同构✅(空列表)
页面POST /pages/、POST /pages/move、POST /pages/extract❌ 未实测
共享GET+POST /shared-nodes/、GET+PATCH /shared-nodes/access/{node_id}、GET /shared-nodes/folder/{parent_id}、GET /shared-documents/{id}、GET /shared-folders/{id}❌ 未实测

四、认证与首次调用

4.1 拿 token:POST /api/token(必须 JSON)

curl -s -X POST http://127.0.0.1:8138/api/token \
     -H 'Content-Type: application/json' \
     -d '{"username":"admin","password":"q1w2e3r4"}'

实测回显(access_token 截前 260 字符,全文 729 字符):

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMGRiYTI5NC1hOTRmLTQ0NzktODhkZS1jOWRmMGFjNGMxN2YiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJzY29wZXMiOlsibm9kZS5jcmVhdGUiLCJub2RlLnZpZXciLCJub2RlLnVwZGF0ZSIsIm
TOKEN_LEN=729

两个实测约束:编码必须 JSON(form 编码 422,见问题 2);响应同时下发 set-cookie: access_token=...(浏览器 UI 会话即此通道,Cookie 名由 SECURITY__COOKIE_NAME 配置,默认 access_token,见《Papermerge 3.5.3 配置参数详解》5.5)。

官方文档说 token 用镜像内 create_token.sh 生成——3.5.3 实测该脚本已损坏(could not find task "auth-cli"),登录换 JWT 是唯一可用路线(问题 3)。

4.2 JWT 里有什么

对 4.1 的 token 做 base64 解码 payload(实测回显):

{"sub": "a0dba294-a94f-4479-88de-c9df0ac4c17f", "preferred_username": "admin", "email": "admin@example.com", "scopes": ["node.create", "node.view", "node.update", "node.delete", "node.move", "document.upload", "document.download", "task.ocr", "tag.create", "tag.view", "tag.update", "tag.delete", "user.me", "user.create", "user.view", "user.update", "user.delete", "group.create", "group.view", "group.update", "group.delete", "ocrlang.view", "page.view", "page.update", "page.move", "page.extract"], "exp": 1793612454}
  • sub 是用户 UUID(不是用户名);scopes 决定能调哪些端点;
  • 有效期:实测新 token 的 exp 距签发时刻 剩余秒 = 21599 ≈ 359 分钟,与默认配置 SECURITY__TOKEN_EXPIRE_MINUTES=360 吻合;
  • token 长度随 scopes 变化:超级用户 729 字符,无权限的普通用户仅 257 字符(见 5.7 与问题 9)。

4.3 带 token 与不带 token

curl -s -o /dev/null -w 'HTTP=%{http_code}\n' http://127.0.0.1:8138/api/users/me
curl -s http://127.0.0.1:8138/api/users/me -H "Authorization: Bearer $TOKEN"
HTTP=401
Unauthorized

401 由 nginx auth_request 拦截产生(响应体是纯文本 Unauthorized,不是 JSON);通过网关后业务层的拒绝则是 JSON {"detail":"Access Forbidden"}(403,见 5.7)。

4.4 首次调用三连

curl -s http://127.0.0.1:8138/api/version/ -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w 'HTTP=%{http_code} bytes=%{size_download}\n' http://127.0.0.1:8138/api/probe/ -H "Authorization: Bearer $TOKEN"
curl -s http://127.0.0.1:8138/api/users/me -H "Authorization: Bearer $TOKEN"

实测回显:

{"version":"3.5.3"}
HTTP=200 bytes=0
{"id":"a0dba294-a94f-4479-88de-c9df0ac4c17f","username":"admin","email":"admin@example.com","created_at":"2026-11-01T16:07:15.292229","updated_at":"2026-11-01T16:07:15.292229","home_folder_id":"6a1bdab1-a5ca-4475-be7e-627631ba8a02","inbox_folder_id":"a6e59095-e270-4f9a-9493-92f8a723d291","is_superuser":true,"is_active":true,"scopes":["custom_field.create","custom_field.delete", ...全量清单... ]}

users/me 的 inbox_folder_id 是后续所有建节点操作的根——Papermerge 每个用户有 home/inbox 两个隐式根目录,新建 folder/document 的 parent_id 从这里取(见 5.1)。

五、各 API 域详解

以下命令均在 export TOKEN=<4.1 所得> 后实测;为省版面,http://127.0.0.1:8138 以 $BASE 代指。

5.1 nodes:目录与文档节点

建目录(ctype=folder,parent_id 必填):

curl -s -X POST $BASE/api/nodes/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"finance","ctype":"folder","parent_id":"a6e59095-e270-4f9a-9493-92f8a723d291"}'

实测回显(提取 id):

FID=48f098ee-8f86-4a24-ae84-bd11d4efdb8c

建文档节点(ctype=document,可带 lang 与 ocr 标志):

curl -s -X POST $BASE/api/nodes/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"pm-invoice","ctype":"document","lang":"deu","ocr":false,"parent_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c"}'
DOCID3=a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd

不带 parent_id 实测 422(问题 4):

{"detail":[{"type":"missing","loc":["body","NewDocument","parent_id"],"msg":"Field required",...}]}

列目录内容(GET /api/nodes/{parent_id},分页信封):

curl -s $BASE/api/nodes/48f098ee-8f86-4a24-ae84-bd11d4efdb8c -H "Authorization: Bearer $TOKEN"

实测回显(节选,finance 目录下两个文档):

{"page_size":5,"page_number":1,"num_pages":1,"items":[{"id":"d39190b7-45b6-420e-ba87-465eff974cd4","title":"pm-d5","ctype":"document","tags":[],"parent_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c","document_type_id":null,"breadcrumb":[],"ocr":false,"ocr_status":"UNKNOWN","thumbnail_url":"/api/thumbnails/d39190b7-45b6-420e-ba87-465eff974cd4","preview_status":null,"user_id":"a0dba294-a94f-4479-88de-c9df0ac4c17f","group_id":null,"owner_name":null,"perms":[],"is_shared":false}, ...]}

注意:裸 GET /api/nodes/ 实测返回空数组 [](HTTP 200)——它不是"全局节点列表",列目录必须带父节点 id。

节点详情(GET /api/documents/{id},含面包屑):

curl -s $BASE/api/documents/b55c1248-fb18-4f75-b41f-befbbc9e6243 -H "Authorization: Bearer $TOKEN"

实测回显(节选,pm-notes 移动后的面包屑):

{"id":"b55c1248-...","title":"pm-notes","ctype":"document",...,"breadcrumb":[["a6e59095-e270-4f9a-9493-92f8a723d291","inbox"],["48f098ee-8f86-4a24-ae84-bd11d4efdb8c","finance"],["b55c1248-fb18-4f75-b41f-befbbc9e6243","pm-notes"]],"ocr":true,"ocr_status":"UNKNOWN",...}

改名(PATCH /api/nodes/{node_id}):

curl -s -X PATCH $BASE/api/nodes/b55c1248-fb18-4f75-b41f-befbbc9e6243 \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"pm-notes-renamed"}'

实测回显(PATCH 响应是精简节点对象,字段集与 GET 详情不同——以回显为准):

HTTP=200
{"id":"b55c1248-fb18-4f75-b41f-befbbc9e6243","title":"pm-notes-renamed","ctype":"document","tags":[],"created_at":"2026-11-02T00:49:54.614163","updated_at":"2026-11-02T04:57:32.244495","parent_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c","user_id":"a0dba294-a94f-4479-88de-c9df0ac4c17f","document":null}

移动(POST /api/nodes/move,body 为 source_ids 数组 + target_id):

curl -s -X POST $BASE/api/nodes/move -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"source_ids":["b55c1248-fb18-4f75-b41f-befbbc9e6243"],"target_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c"}'
HTTP=200
["b55c1248-fb18-4f75-b41f-befbbc9e6243"]

移动结果用上面的面包屑回显验证(pm-notes 的 parent 变为 finance)。

删除节点(DELETE /api/nodes/,body 必须是裸 UUID 数组):

curl -s -X DELETE $BASE/api/nodes/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '["d39190b7-45b6-420e-ba87-465eff974cd4"]'
HTTP=200
null

包成对象 {"node_ids":[...]} 实测 422(问题 5):

{"detail":[{"type":"list_type","loc":["body"],"msg":"Input should be a valid list","input":{"node_ids":["d39190b7-45b6-420e-ba87-465eff974cd4"]}}]}

5.2 documents:上传、版本与下载

上传(POST /api/documents/{id}/upload,multipart)。文档节点必须先建(5.1),上传只是给节点灌文件:

curl -s -X POST $BASE/api/documents/d39190b7-45b6-420e-ba87-465eff974cd4/upload \
  -H "Authorization: Bearer $TOKEN" -F "file=@invoice-scan.pdf"

实测回显(完整文档对象原文照抄):

{"id":"d39190b7-45b6-420e-ba87-465eff974cd4","title":"pm-d5","ctype":"document","tags":[],"parent_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c","document_type_id":null,"breadcrumb":[],"ocr":false,"ocr_status":"UNKNOWN","thumbnail_url":"/api/thumbnails/d39190b7-45b6-420e-ba87-465eff974cd4","preview_status":null,"user_id":"a0dba294-a94f-4479-88de-c9df0ac4c17f","group_id":null,"owner_name":"admin","perms":[],"is_shared":false,"versions":[{"id":"6ed32054-c4bf-4c5d-9aea-aa1090653ea0","number":1,"lang":"deu","file_name":"invoice-scan.pdf","size":81232,"page_count":1,"short_description":"Original","document_id":"d39190b7-45b6-420e-ba87-465eff974cd4","download_url":"/api/document-versions/6ed32054-c4bf-4c5d-9aea-aa1090653ea0/download","pages":[{"id":"3f5d67e0-f046-41f9-9bc0-6ee35674b3d6","number":1}]}]}

要点:响应即最新版本快照(versions[0] 含 size/page_count/pages/download_url);lang 取文档节点的 lang;若建节点时 ocr=true,上传即自动调度 OCR(5.3)。

版本列表与最新版本:

curl -s $BASE/api/documents/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd/versions -H "Authorization: Bearer $TOKEN"
curl -s $BASE/api/documents/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd/last-version/ -H "Authorization: Bearer $TOKEN"

实测回显:

[{"id":"665be4ac-5fd7-48aa-80c8-94d390b36e89","number":2,"short_description":"With OCR text layer"},{"id":"7371b12f-0f45-48e5-9e9b-2da9fa490d68","number":1,"short_description":"Original"}]
{"id":"665be4ac-5fd7-48aa-80c8-94d390b36e89","number":2,"lang":"deu","file_name":"invoice-scan.pdf","size":0,"page_count":1,"short_description":"With OCR text layer","document_id":"a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd","download_url":"/api/document-versions/665be4ac-5fd7-48aa-80c8-94d390b36e89/download","pages":[{"id":"7a30ce83-ae00-4bc6-b638-27387140c421","number":1}]}

OCR 完成后自动产生 number:2 的 "With OCR text layer" 版本(原始版本保留,可回溯)。注意 last-version 里 OCR 版 size:0 是显示怪癖,实际文件 50126 字节(见下面下载)。

下载 URL 与实际下载:

curl -s $BASE/api/document-versions/665be4ac-5fd7-48aa-80c8-94d390b36e89/download-url -H "Authorization: Bearer $TOKEN"
curl -s $BASE/api/document-versions/665be4ac-5fd7-48aa-80c8-94d390b36e89/download -H "Authorization: Bearer $TOKEN" -o /tmp/pm-dl.pdf
{"downloadURL":"/api/document-versions/665be4ac-5fd7-48aa-80c8-94d390b36e89/download"}
downloaded version=665be4ac-5fd7-48aa-80c8-94d390b36e89 bytes=50126 sha256=b400cab5c5160e3f

download-url 返回的是站内相对路径(FILE_SERVER=s3 形态下才是 CloudFront 签名外链,见《Papermerge 3.5.3 配置参数详解》5.6);GET /download 直接回文件字节流。

缩略图:

curl -s -o /tmp/thumb.jpg -w 'HTTP=%{http_code} bytes=%{size_download} type=%{content_type}\n' \
  $BASE/api/thumbnails/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd -H "Authorization: Bearer $TOKEN"
HTTP=200 bytes=5147 type=image/jpeg

尺寸由 PREVIEW__PAGE_SIZE_SM 控制(默认 200px)。

挂文档类型与自定义字段值:

curl -s -X PATCH $BASE/api/documents/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd/type \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"document_type_id":"d94c424d-e74e-4aa0-8b77-4d95171145af"}'
curl -s -X PATCH $BASE/api/documents/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd/custom-fields \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '[{"key":"InvoiceNr","value":"PM-2026-0001"}]'
curl -s $BASE/api/documents/a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd/custom-fields -H "Authorization: Bearer $TOKEN"

实测回显(依次为:设类型 200 空体;赋值 200;回读带值):

HTTP=200
null
HTTP=200
[{"document_id":"a760b7a0-...","document_type_id":"d94c424d-...","custom_field_id":"061e84fb-...","name":"InvoiceNr","type":"text","extra_data":null,"custom_field_value_id":null,"value":null}]
[{"document_id":"a760b7a0-...","document_type_id":"d94c424d-...","custom_field_id":"061e84fb-...","name":"InvoiceNr","type":"text","extra_data":null,"custom_field_value_id":"b041afe3-02d1-4197-bf9a-1b8fdd0082a5","value":"PM-2026-0001"}]

顺序敏感:必须先给文档设类型,再 PATCH 字段值——类型未设时赋值回读 [](实测)。custom-fields PATCH 的 body 是 {"key":字段名,"value":值} 的裸数组。

5.3 OCR 任务

手动调度(POST /api/tasks/ocr,body 为 document_id + lang):

curl -s -X POST $BASE/api/tasks/ocr -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"document_id":"a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd","lang":"deu"}'
null

HTTP 200 + 空体 null=已投递 ocr 队列,异步执行;单页 PDF 从投递到文本入库实测约 1-2 秒。建节点时 "ocr":true 则上传即自动调度(实测 pm-notes 上传后 ocr=True)。

lang 参数校验:传非白名单码实测 422 并回显全部合法码(24 个):

{"detail":[{"type":"literal_error","loc":["body","lang"],"msg":"Input should be 'ces', 'dan', 'deu', 'ell', 'eng', 'fas', 'fin', 'fra', 'guj', 'heb', 'hin', 'ita', 'jpn', 'kor', 'lit', 'nld', 'nor', 'pol', 'por', 'ron', 'san', 'spa', 'kaz' or 'rus'","input":"zzz","ctx":{"expected":"'ces', 'dan', 'deu', 'ell', 'eng', 'fas', 'fin', 'fra', 'guj', 'heb', 'hin', 'ita', 'jpn', 'kor', 'lit', 'nld', 'nor', 'pol', 'por', 'ron', 'san', 'spa', 'kaz' or 'rus'"}}]}

白名单 ∩ 镜像 tesseract 语言包(实测仅 deu/eng/osd/ron/spa)才是真正可用集;白名单内缺包(如 jpn)照样受理返回 null,运行时才失败(问题 10)。

没有任务状态端点:openapi 里 /tasks/ 仅 POST /tasks/ocr 一个路径。验证 OCR 是否真跑完,实测看三处——Redis 的 celery-task-meta-*(全 SUCCESS)、media 盘 ocr/pages/.../page.txt 产物、以及 GET /api/documents/{id} 的 ocr 布尔位(注意 ocr_status 实测恒为 UNKNOWN,不可依赖)。

5.4 tags:标签

建标签(POST /api/tags/,颜色可自定义):

curl -s -X POST $BASE/api/tags/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"invoice-2026","bg_color":"#FF0000","fg_color":"#FFFFFF"}'
{"id":"f58b3abc-97e0-4c49-a03a-631c7bd097fd","name":"invoice-2026","bg_color":"#FF0000","fg_color":"#FFFFFF","description":null,"pinned":false,"group_id":null,"group_name":null}

列表:GET /api/tags/ 分页信封(默认 page_size=5),GET /api/tags/all 裸数组(实测回显为后来自动重建的默认色标签):

{"page_size":5,"page_number":1,"num_pages":1,"items":[{"id":"373b1c53-a136-4f0f-a708-1578adb40e2c","name":"invoice-2026","bg_color":"#c41fff","fg_color":"#FFFFF","description":null,"pinned":false,"group_id":null,"group_name":null}]}
[{"id":"373b1c53-a136-4f0f-a708-1578adb40e2c","name":"invoice-2026","bg_color":"#c41fff","fg_color":"#FFFFF","description":null,"pinned":false,"group_id":null,"group_name":null}]

给节点打标签(PATCH /api/nodes/{node_id}/tags)——本文最大的 API 坑:openapi schema 是 array[string],但字符串按"标签名"解释,不是标签 UUID(问题 6)。实测行为链:

  • 传 ["<tag_uuid>"] → 按名找不到就新建一个名字是那串 UUID 的标签并挂上(实测节点 tags 出现 {"name":"f58b3abc-97e0-...","bg_color":"#c41fff"});
  • 传已存在的名字 ["invoice-2026"] → 400 直接把数据库唯一约束错误抛给客户端:
{"detail":{"attrs":null,"messages":["(sqlalchemy.dialects.postgresql.asyncpg.IntegrityError) <class 'asyncpg.exceptions.UniqueViolationError'>: duplicate key value violates unique constraint \"unique tag name per user\"..."]}}HTTP=400
  • 传不存在的名字 → 自动建默认色标签(bg #c41fff)并挂载;实测 HTTP 500 但副作用生效(响应码不可信,以回读为准)。

正确姿势:先 POST /api/tags/ 建好标签,再 PATCH /api/nodes/{id}/tags -d '["invoice-2026"]'——名字在全用户唯一(unique tag name per user),回读节点详情 tags 数组确认。删除标签 DELETE /api/tags/{tag_id} 实测 204。

5.5 document-types 与 custom-fields

建自定义字段(POST /api/custom-fields/,type 支持 text/number/date 等):

curl -s -X POST $BASE/api/custom-fields/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"InvoiceNr","type":"text"}'
{"id":"061e84fb-1c2b-4aa7-8be4-cec63b08fab4","name":"InvoiceNr","type":"text","extra_data":null,"group_id":null,"group_name":null}

建文档类型(POST /api/document-types/)——name 与 custom_field_ids 都是必填(问题 7),实测缺字段的 422:

{"detail":[{"type":"missing","loc":["body","name"],"msg":"Field required","input":{"label":"Rechnung","path":"finance/"}},{"type":"missing","loc":["body","custom_field_ids"],"msg":"Field required","input":{"label":"Rechnung","path":"finance/"}}]}

(顺带可见:文档类型 JSON 里用 label/path 是 2.x 时代字段,3.5.3 不认。)正确创建:

curl -s -X POST $BASE/api/document-types/ -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Rechnung5","custom_field_ids":["061e84fb-1c2b-4aa7-8be4-cec63b08fab4"]}'
{"id":"d94c424d-e74e-4aa0-8b77-4d95171145af","name":"Rechnung5","path_template":null,"custom_fields":[{"id":"061e84fb-1c2b-4aa7-8be4-cec63b08fab4","name":"InvoiceNr","type":"text","extra_data":null,"group_id":null,"group_name":null}],"group_id":null,"group_name":null}

列表:GET /api/document-types/(分页信封)与 /all(裸数组)、GET /api/custom-fields/ 与 /all 同 5.4 规律,实测回显见上两例结构。类型与字段的单条 GET/PATCH/DELETE /{id} 端点存在(openapi 53 路径),本文未逐一展开。

5.6 search:全文检索

curl -s "$BASE/api/search/?q=KAFFEEMUEHLE" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/search/?q=778811" -H "Authorization: Bearer $TOKEN"

实测回显(最终修复态):

{"page_size":10,"page_number":1,"num_pages":1,"items":[{"id":"88a68182-b667-4174-ac68-2e49c0f5847e","title":"invoice-scan","lang":"en","tags":[],"page_number":1,"document_id":"125122b8-baca-4533-a47b-e64bafa48204","entity_type":"document"},{"id":"9e70675f-4504-4c8b-84ff-f78bc87bd08d","title":"invoice-fresh","lang":"en","tags":[],"page_number":1,"document_id":"2a4692ef-d728-4c2d-924d-d8e23b91d305","entity_type":"document"},{"id":"7a30ce83-ae00-4bc6-b638-27387140c421","title":"pm-invoice","lang":"en","tags":[],"page_number":1,"document_id":"a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd","entity_type":"document"}]}
{"page_size":10,"page_number":1,"num_pages":1,"items":[{"id":"3a6508af-1199-4100-8154-3f9ad27b7f23","title":"notes-text","lang":"en","tags":[],"page_number":1,"document_id":"7b3e8499-fcdc-4e33-bd81-84d2a4ed3843","entity_type":"document"},{"id":"84585308-cead-4240-846b-fa97c800148b","title":"pm-notes","lang":"en","tags":[],"page_number":1,"document_id":"b55c1248-fb18-4f75-b41f-befbbc9e6243","entity_type":"document"}]}

解读(全部实测):

  • q=KAFFEEMUEHLE(纯扫描件 OCR 出的词)命中 3 条、q=778811(PDF 自带文本层)命中 2 条——OCR 文本与原生文本层同权入索引;q=invoice 还能命中标题(title 也 copyField 进 _text_)。
  • 命中项是页粒度:id 是页 UUID,document_id 才是文档;page_number 为命中页码;entity_type 实测均为 document。
  • 结果自动按用户隔离:索引文档带 user_id 字段,webapp 发出的查询是 q=<词> AND user_id:<当前用户>(Solr 访问日志实测可见),无需也无法手动传用户。
  • 分页:page_size(默认 10,openapi schema)与 page_number(默认 1),实测 page_size=1 两页:
{"page_size":1,"page_number":1,"num_pages":4,"items":[{"id":"88a68182-...","title":"invoice-scan", ...}]}
{"page_size":1,"page_number":2,"num_pages":4,"items":[{"id":"9e70675f-...","title":"invoice-fresh", ...}]}
  • 检索 0 命中先查索引层而不是 API:官方默认 Solr configset 缺 copyField 时本端点恒空(部署指南第九章问题 4/6/7)。

5.7 users / groups / roles

建用户(POST /api/users/)——role_ids 与 group_ids 必填(可空数组,问题 8),实测缺字段的 422:

{"detail":[{"type":"missing","loc":["body","role_ids"],"msg":"Field required","input":{"username":"quzuo","email":"quzuo@example.com","password":"q1w2e3r4","is_superuser":false,"is_active":true}},{"type":"missing","loc":["body","group_ids"],"msg":"Field required","input":{"username":"quzuo","email":"quzuo@example.com","password":"q1w2e3r4","is_superuser":false,"is_active":true}}]}

补齐后创建成功(实测回显,新用户自动获得自己的 home/inbox 根目录):

{"id":"1aee9e47-8173-4c80-a688-3351c4197eac","username":"quzuo","email":"quzuo@example.com","created_at":"2026-11-02T01:20:58.986077","updated_at":"2026-11-02T01:20:58.986077","home_folder_id":"9662aba9-71f9-46b0-bedf-2f11bf455b13","inbox_folder_id":"c9ec56d7-3e04-4ff9-8d69-d9c49bb3954e","is_superuser":false,"is_active":true,"scopes":[]}

列表:GET /api/users/ 分页信封(admin 实测见 2 用户);GET /api/users/all 裸数组。GET /api/users/me 见 4.4。

权限实测:上面这样"裸建"的用户 scopes 为空——用它登录拿到的 token 只有 257 字符,调 GET /api/users/ 与 GET /api/users/me 都返回 403:

users HTTP=403
{"detail":"Access Forbidden"}

即 3.5.3 里"建用户 ≠ 授权":权限要走 roles/groups(POST /api/roles/、POST /api/groups/ 及用户体的 role_ids/group_ids)赋予;本实例未建任何角色/组,实测 GET /api/roles/、GET /api/groups/ 均为 {"page_size":5,"page_number":1,"num_pages":0,"items":[]}。POST /api/users/{id}/change-password、GET /api/users/group-homes|group-inboxes 端点在 openapi 中存在,本文未实测。

六、分页 / 过滤 / 排序通用约定

实测归纳的 API 通用规律:

  1. 两种响应形态:列表端点默认返回分页信封 {"page_size":N,"page_number":N,"num_pages":N,"items":[...]};带 /all 后缀的端点(tags/all、document-types/all、custom-fields/all、users/all、groups/all、roles/all)返回裸数组、无分页。列表默认 page_size=5(实测各列表回显),search 默认 page_size=10(openapi schema)。
  2. 例外:GET /api/nodes/(不带父 id)实测返回裸 [],不是信封也不是全量列表——列内容请用 GET /api/nodes/{parent_id}。
  3. 主键一律 UUID;路径参数名不统一(/documents/{document_id}、/document-versions/{doc_ver_id}……),以 /openapi.json 为准。
  4. 时间戳 ISO8601 无时区后缀(如 2026-11-02T01:20:58.986077),按 MAIN__TIMEZONE 配置解释。
  5. 错误体分三层:网关 401 是纯文本 Unauthorized;业务层 401/403/404 是 {"detail":"..."} 字符串;FastAPI 校验 422 是 {"detail":[{type,loc,msg,input,ctx}]} 数组;个别 400/500 会直接泄漏 SQLAlchemy 异常全文(5.4 实测)。
  6. 写操作响应码与副作用可能不一致(标签自动创建实测 500 但生效),批量脚本务必回读验证。
  7. 过滤/排序参数:本文实测的列表端点未见 query 过滤参数(openapi 中 nodes/documents 列表仅分页类参数);检索过滤靠 q 语法与用户隔离,标题/标签过滤实测只能拿到全量后自行筛选。

七、常见问题(API 坑实录)

问题 1:路径加 /v1 一律 404

/api/v1/tasks/ocr 实测返回 {"detail":"Not Found"}(webapp 日志 "POST /v1/tasks/ocr HTTP/1.0" 404)。3.5.3 的 OpenAPI 路径就没有版本段,nginx 剥掉 /api 后转发;一律按 /api/<openapi.json 里的路径> 写。

问题 2:登录用 form 编码 422

POST /api/token 只接受 JSON(-H 'Content-Type: application/json');x-www-form-urlencoded 实测 422。

问题 3:官方 create_token.sh 在 3.5.3 镜像内损坏

官方 REST API token 文档给的是 docker exec <webapp> create_token.sh <username>;实测报 could not find task "auth-cli"(脚本调 poetry task,镜像 pyproject 未注册),list_users.sh 同病。唯一可用路线:POST /api/token 换 JWT(4.1)。

问题 4:POST /api/nodes/ 忘带 parent_id

422 Field required(loc ["body","NewDocument","parent_id"])。父节点取 GET /api/users/me 的 inbox_folder_id(4.4)。

问题 5:DELETE /api/nodes/ 包对象报 422

{"node_ids":[...]} 实测 422 Input should be a valid list;裸 UUID 数组才 200(5.1)。

问题 6:给节点打标签传的是名字不是 UUID

PATCH /api/nodes/{id}/tags 的 array[string] 按标签名解释:传 UUID 会建出"名字是 UUID"的垃圾标签;传已存在的名字撞唯一约束 400;传新名字自动建默认色标签(实测 500 但生效)。正确姿势见 5.4。

问题 7:document-types 创建缺 custom_field_ids

name 与 custom_field_ids 均必填(空数组可);2.x 风格的 label/path 字段 3.5.3 不认(5.5 实测 422)。

问题 8:users 创建缺 role_ids/group_ids

均必填(空数组可),实测 422(5.7)。

问题 9:新建用户什么都调不通(403)

裸建用户 scopes:[],连 /users/me 都 403;token 长度骤减(257 vs 超管 729)可作快速判据。授权走 roles/groups(5.7)。

问题 10:OCR 返回 null ≠ 成功

POST /api/tasks/ocr 受理即 null;lang 在白名单但镜像缺 tessdata(如 jpn)时任务运行时 FAILURE。可用语言 = 24 码白名单 ∩ 镜像 5 语言包 = deu/eng/ron/spa(5.3 与《Papermerge 3.5.3 配置参数详解》5.4)。

问题 11:检索恒 0 命中

先怀疑索引层(Solr schema 缺 copyField / page_number 段冲突 / 误跑 webapp 的 index-schema apply),API 本身没错;修复与诊断链见部署指南第九章问题 4/6/7。

八、参考链接