# REST API 总览 QuantumAtlas server 的所有 HTTP endpoint。auth 模型详见 [概念/鉴权](../concepts/auth-model.md)。 ## 交互式 API 文档(Swagger UI) server 内嵌了一份由 [swaggo](https://github.com/swaggo/swag) 从代码注解自动生成的 OpenAPI spec,挂在 **`/swagger`**(如 ),可在浏览器里 直接浏览/点测每个 endpoint: - `GET /swagger/index.html` — Swagger UI 页面(公开,无需 auth)。 - `GET /swagger/doc.json` — 原始 OpenAPI 2.0 JSON,可喂给 Postman / openapi-generator 等。 写口在 UI 里点 **Authorize** 填 `Bearer ` 即可带鉴权调用。 > 历史镜像说明:这份 spec 也嵌进了文档站:[API Explorer](api-explorer.md) 页可在浏览器里 > 展开浏览全部 endpoint 的参数 / schema(静态镜像,在线点测仍用上面的 `/swagger`)。 上段保留的是旧版文档镜像的说明。当前 Sphinx 站点不运行旧版镜像 hook,API Explorer 改为提供原生 OpenAPI spec 下载与当前实例 `/swagger/` 的普通链接;本页的静态表格 仍可直接阅读。Swagger UI 本身不是只读界面,真实 API 调用须由用户主动选择操作, 并通过服务端鉴权。 ### spec 怎么来的(维护者须知) PocketBase 的路由是匿名闭包,没有可挂 doc 注释的具名 handler,所以注解集中写在 [`internal/routes/openapi.go`](https://github.com/IAI-USTC-Quantum/QuantumAtlas/blob/main/internal/routes/openapi.go) 的一组 no-op stub 函数上(每个 endpoint 一个),general info 写在 `cmd/qatlasd/main.go` 的 `func main()` 之上。生成产物落在 `internal/apidocs/`(被 `main.go` blank-import,编译进二进制)。 改完注解后重新生成: ```bash go tool swag init -g main.go -d ./cmd/qatlasd,./internal/routes \ -o internal/apidocs --parseInternal --parseDepth 1 git diff -- internal/apidocs ``` swag CLI 通过 `go.mod` 的 `tool` 指令钉版本(`go tool swag`),生成是确定性的。CI (`.github/workflows/go.yml`)执行相同命令,并用 `git diff --exit-code` 与包含未跟踪文件的状态检查防漂移。 应提交 `internal/apidocs/` 的生成源码;不能只修改注解而忘记同步 spec。 > 警告:注解里的 path / 参数 / 响应是**手写声明**,不是从真实闭包反射出来的——swaggo 在任何 > 非 net/http-mux 风格路由上都这样。它和真实行为的一致性靠 code review + 这份手维护的 > Markdown 表交叉验证,不是自动保证。 ## 公开端点(不需要 Authorization 头) > 仅以下"无语料数据"端点保持公开:探活 / 版本 / 安装脚本 / API 文档 / scope 词表 / SPA 外壳。**论文数据本身不再匿名可读**——搜索、统计、论文资产等读口都已收敛到 `*:read` scope(见下方鉴权端点)。 | Method | Path | 用途 | |---|---|---| | `GET` | `/api/health` | 健康检查 + 依赖探活 | | `GET` | `/api/server/info` | 版本 / 引擎信息 + **能力发现**(`capabilities`:匿名可见布尔位 `paper_access` / `markdown_delivery` / `pdf_delivery`(随 paper_access 开关;已存 PDF 无需解析 token)/ `agentic_search` / `mineru.enabled` / `mineru.on_demand`;认证调用者额外见 `mineru.daily_cap` 与 `mineru.converted_today`,来自批处理调度器快照)| | `GET` | `/install-qatlasd.sh` | qatlasd 安装脚本 | | `GET` | `/swagger/index.html` | 交互式 API 文档(Swagger UI)| | `GET` | `/swagger/doc.json` | OpenAPI 2.0 JSON spec | | `GET` | `/api/pat/scopes` | 列 PAT scope 词表(纯常量,无用户数据)| | `GET` | `/{path...}` | SPA 前端静态外壳(数据在被门禁的 API 后面)| | `GET` | `/_/` ... | PocketBase admin UI | ## 鉴权端点(需要 PAT 或 session token) ### Papers | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `GET` | `/api/papers` | `papers:read` | 论文分页列表。过滤参数:`has_md`(默认资产已有 markdown)/ `status`(`pending \| ready \| failed`)/ `q`(标题子串,大小写不敏感)/**`arxiv_id` / `doi` / `paper_id`**(精确身份过滤——`arxiv_id` 自动去 `vN` 版本后缀,`doi` 容忍 `https://doi.org/` URL 前缀后归一);`page` / `per_page`(默认 20,≤100)/ `sort`(`created_at` 默认 \| `updated_at`,降序)| | `GET` | `/api/papers/{id}` | `papers:read` | 论文详情(registry 记录 + 资产行)。`{id}` 接受**三种标识符**:`qa_` paper_id、arXiv ID(新式 `2501.00010` / 老式 `quant-ph/9508027`,带或不带 `vN`)、DOI(含 `https://doi.org/…` URL 形态)。合法但未收录的标识符返回 404,`detail` 提示用 `GET /api/papers/lookup?ids=arxiv:…` 解析元数据 | | `GET` | `/api/papers/lookup?ids=` | `papers:read` | 批量(**≤200 条**)解析 `kind:id` 引用(`arxiv:` / `doi:` / `openalex:`)。每项返回 `{ref, title, authors, year, hosted, has_md, resolved}`——`hosted` 表示是否已收录,`has_md`(hosted 时有意义)表示默认资产是否已有 markdown。**这是批量核对「有没有 markdown」的官方入口**,无需逐篇调详情 | | `GET` | `/api/papers/stats` | `papers:read` | 论文资产统计(`available`、`total`、`has_pdf`、`has_md`、`has_json`、`needs_mineru`、`total_images`、`loaded_at`);paperindex 不可用时返回 `{available:false}` | | `GET` | `/api/papers/needs-mineru?limit=&include_claimed=` | `papers:read` | 列等待 MinerU 解析的论文 | | `POST` | `/api/papers/{arxiv_id}/upload-pdf` | `papers:write` | 上传 PDF,见 [Upload API](upload-api.md) | | `POST` | `/api/papers/{arxiv_id}/upload-mineru` | `papers:write` | 上传完整受支持 Middle + Markdown ZIP;所有原名/字节保留,重传新 revision;MD-only 拒收| | `POST` | `/api/v1/papers/{arxiv_id}/mineru-lease` | `papers:write` | 申请 MinerU 处理 lease(响应字段含 `claim_id`)| | `DELETE` | `/api/v1/papers/{arxiv_id}/mineru-lease/{claim_id}` | `papers:write` | 释放 MinerU lease | | `POST` | `/api/papers/{arxiv_id}/mineru-claim` | `papers:write` | 申请 MinerU 处理 lease | | `DELETE` | `/api/papers/{arxiv_id}/mineru-claim/{claim_id}` | `papers:write` | 释放 MinerU lease | | `GET` | `/api/papers/{id_or_doi}/markdown` | `papers:read` | **PAPER_ACCESS** · 所选完整不可变 bundle 的原始 Markdown 字节;format=link 仅返回同源认证成员 locator。无 pin 未就绪时 202 →纯 read/status 轮询→带 source/revision 重取;旧 MD/JSON/images 忽略 | | `GET` | `/api/papers/{id_or_doi}/markdown/status` | `papers:read` | **PAPER_ACCESS** · 与 read/status 共享完整包/冻结来源 readiness 纯轮询,不启动下载/解析;顶层 source_id/revision/state/ready(兼容 flags 可附加) | | `GET` | `/api/papers/{id_or_doi}/pdf` | `papers:read` | **PAPER_ACCESS** · 鉴权固定来源 PDF 字节,source_id 或语义 version=vN 互斥;完整 SHA headers,Range 同样鉴权;无需先枚举 source 或 MinerU token。format=link 仅返回同源认证 locator | | `GET` | `/api/papers/{id_or_doi}/pdf/status` | `papers:read` | **PAPER_ACCESS** · PDF 来源就绪纯探测,不解析;若含 pdf_url 仅是同源固定 source 的认证 locator,不暴露对象桶 | | `GET` | `/api/papers/{id_or_doi}/images/zip` | `papers:read` | **PAPER_ACCESS** · 从完整 bundle 核验原名/字节后组装图片 ZIP;无 pin 未就绪可202,纯 read/status 轮询后重取固定 source/revision;无 NAS presign | | `GET` | `/api/papers/{id}/read` | `papers:read` | **PAPER_ACCESS** · Middle 派生 Markdown JSON 信封;source_id/revision/page/block/cursor/limit,1-based定位、预算1..100000默认30000;truncated/next_request.cursor 固定全部身份 | | `GET` | `/api/papers/{id}/read/status` | `papers:read` | **PAPER_ACCESS** · 纯轮询;ready须完整包+冻结PDF核验,顶层source_id/revision;pending202 + Operation-Location/Retry-After,不创建任务 | | `GET` | `/api/papers/{id}/parses/{revision}/manifest` | `papers:read` | **PAPER_ACCESS** · 已发布完整包清单,source PDF与每成员SHA/原path;不以旧Middle代替 readiness | | `GET` | `/api/papers/{id}/parses/{revision}/files/{original_relative_path}` | `papers:read` | **PAPER_ACCESS** · 所有生产者成员原名/原字节,含嵌套目录及全部JSON,不重命名/重序列化 | | `GET` | `/api/papers/{id}/parses/{revision}/json` | `papers:read` | **PAPER_ACCESS** · 原始Middle字节固定artifact SHA;历史评论仍用,不是read派生JSON,也不是完整包ready证据 | 完整身份、原件、懒迁移、上传与错误规则见[论文内容契约](paper-content.md)。当前新内容不运行自动 nightly/boot/入库转换;只有显式内容访问懒解析,管理员显式 RunNow/batch 另按授权。 > `papers:write` 隐式含 `papers:read`。 > > **PAPER_ACCESS** 及 raw JSON/block 原件/完整包成员/images/figures **均由 YAML `paper_access.enabled` 控制** > (默认 OFF;关闭 404,无存储/解析副作用;qa_、别名、Range、历史 pin 不绕过)。开启等于自愿承担对外重分发 > PDF / markdown 字节(或 RAG snippet 形式的派生片段)的合规义务,见 > [License & Attribution · 论文访问开关](../about/license-and-attribution.md#论文访问开关-self-hosted)。 > 公共 `quantum-atlas.ai` 部署默认 OFF。 > > `{id_or_doi}` 接受qa_、明确/裸arXiv或DOI别名。规范作品ID不等于版本pin; > source_id或version=vN是确切来源选择,revision/cursor固定解析。先解析明确pin, > 不按旧“DOI总优先/最高版本”规则替换。默认来源歧义应显式pin;未知论文/来源、 > 关开关和后端不可用分别报告,不将旧MD/JSON/images当新ready。 ### Search | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `POST` | `/api/search` | `papers:read` | 多 provider 论文搜索。body 为 SearchEntry JSON,engine fan-out 到 `search.providers`(config.yaml,默认 `catalog,arxiv,openalex`)列出的 provider。`text` / `title` / `arxiv_id` / `doi` **全空时 400**(不再发出空查询);带 `arxiv_id` / `doi` 的请求把身份透传给 qatlas-search 微服务做精确查询(arXiv `id_list` / OpenAlex DOI filter / Semantic Scholar paper 端点)。`results` 每项附带 `has_md` / `has_pdf` / `status`(registry 默认资产摘要;catalog 不可用时省略)| | `POST` | `/api/search/multi` | `papers:read` | **逐平台原始搜索**。body `{text?, doi?, max_results?, sources[]}`(`text` / `doi` 至少一个非空,≤32 个 backend);DOI-only 请求以空 `query` 加 `doi` 透传做身份精确检索,若同时提供非空 `text` 则仍按文本搜索,代理一次 `mode:"multi"` 调用到 qatlas-search 微服务:每个 backend 返回**各自的原始命中列表**(源自己的排序),不做跨源融合打分(融合打分在 `POST /api/search` / `/api/search/agentic`)。调用者存着的第三方 key 被解密后随请求 `api_keys` 转发,key 后端跑在用户自己的凭据下。不计费(同 `POST /api/search`)。`search.remote` 未启用时 503 | | `GET` | `/api/search/backends` | session only | backend 目录(SPA 渲染成 checkbox 选择器):静态表(`internal/search/backendmeta.go`)合并微服务 live `/v1/backends` 可用性 + 调用者已存的 key。每行 `{name, label, category, requires_key, user_key, server_ready, key_configured, selectable}`,`selectable = server_ready \|\| (user_key && key_configured)`——需要 key 但没配的后端渲染为禁用并附"去 dashboard 配置"链接 | | `POST` | `/api/search/agentic` | `papers:read` | 计量 + LLM 总结的 agentic 搜索(qatlas-search 微服务)。body 额外接受 `sources[]` **钉死 backend 列表**(v0.26.0 起;不传则由微服务侧全量 fan-out)。空 entry(`text` / `title` / `arxiv_id` / `doi` 全空)在计量**之前**就 400;身份条目同样透传给微服务;`results` 与 `POST /api/search` 一样附带 `has_md` / `has_pdf` / `status` | 语义向量检索不在 `/api/search` 的 provider 列表里:它由独立的 qatlas-rag 微服务 提供,经 qatlas-search 的 fan-out 接入(`POST /api/search/agentic` 路径), qatlasd 在论文 ready 时通过 `rag.remote` 配置段向 qatlas-rag 推送索引构建。 `/api/search/multi` 与 `/api/search/backends` 同样依赖 `search.remote` 启用。 Web 的普通搜索和 Agentic 搜索会将完整的裸 DOI、`doi:...` 或 `http(s)://doi.org/...` / `http(s)://dx.doi.org/...` 识别为 `doi` 字段, 不再同时发送 `text`。DOI 链接的路径解码一次,查询参数/fragment 不作为 DOI; 包含 DOI 的一句话仍按关键词搜索。远程 DOI 精确检索请选择 OpenAlex 或 Semantic Scholar,其他来源可能不支持;不会替用户改变所选来源。自定义评分仍使用 其显式文本主题与规则流程。DOI 搜索只检索/登记元数据,不自动下载论文。 ### Personal Search Keys(个人第三方搜索 key) 每个用户可存自己的第三方搜索 API key(IEEE / Scopus / Tavily 等 key 后端), `POST /api/search/multi` 调用时自动解密注入。存储是 AES-256-GCM 加密的 `search_api_keys` PocketBase collection(owner-only 规则、仅服务端写入); 加密密钥由 system PAT token 做域分离 SHA-256 派生——**轮换 system PAT 会使 已存 key 全部失效**(表现为解密失败,用户重新录入即可),无独立配置键。 | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `GET` | `/api/me/search-keys` | session only | 当前用户的 key 清单:`{enabled, keys:[{backend, hint, updated_at}]}`。`hint` 是打码的末 4 位,**永不返回 key 本体**。server 未配 system PAT 时 `enabled:false` | | `PUT` | `/api/me/search-keys/{backend}` | session only | upsert 一个 key(body `{"key": "..."}`)。`backend` 必须是目录里有 user-key 槽位的后端,否则 400 | | `DELETE` | `/api/me/search-keys/{backend}` | session only | 删除;不存在时 opaque 404 | > session-only(非 scopeGuard)的理由同 `/api/pat`:这是浏览器 dashboard 里的 > 凭据管理,泄露的 PAT 不应能读走或替换 owner 的第三方 key。 ### Robust Downloader 详见 [Robust Downloader](downloader.md)(策略阶梯 / 配置 / downloaderproxy / probe)。端点本身: | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `POST` | `/api/downloader/fetch` | `papers:write` | 提交一批标识符(DOI / arXiv id / 论文 URL,body `{"items":[...]}`,**≤50 条**)。每条先解析 + `ResolveOrMint` 进 registry,再入策略阶梯队列。逐条返回 `{input, kind, paper_id, created, error?}` + 总 `enqueued`。`paper_access.enabled` / `downloader.enabled` 任一关闭时 503;registry 不可用时 503 | | `GET` | `/api/downloader/jobs` | `papers:read` | job 快照:`{jobs:[...], counters:{...}}`。每个 job 含 per-paper state/phase、胜出策略、**完整 attempt trace**(策略 id / URL / 错误 / 耗时)| SPA 页面 `/$lang/downloader`:粘贴标识符 → 客户端解析预览徽章 → 提交 → 逐 job 进度 + 策略轨迹渲染(挂在 `downloader` 插件上)。 ### Plugins Plugins share one manifest/capability model with two orthogonal axes (`kind` × `transport`). The first-party theorems plugin is `kind=builtin` (compiled into `qatlasd`, in-process); third-party plugins are `kind=external` and connect to the RPC listener over `transport=socket`, or are spawned by the host over `transport=stdio`. | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `GET` | `/api/v1/plugins` | `plugins:read` | 列出发现的插件清单与状态(`connected` / `disconnected` / `disabled` / `incompatible`)| | `POST` | `/api/v1/plugins/{id}/enable` | `plugins:write` | 运行时启用已配置插件 | | `POST` | `/api/v1/plugins/{id}/disable` | `plugins:write` | 运行时禁用已配置插件 | 配置驱动的远程微服务也会出现在插件清单里:``search-remote``(qatlas-search) 与 ``rag-remote``(qatlas-rag)在各自配置段启用后注册,qatlasd 启动后探测 它们的 ``/healthz`` 并标记 ``connected`` / ``disconnected``。 External plugins do not call host capabilities over HTTP. They connect to the WebSocket JSON-RPC service at `QATLAS_RPC_WS_BIND`. `initialize` params contain `id`, `secret`, and `abi_version`; after the handshake the same connection can call the host-shared capabilities `papers/getMarkdown`, `papers/getMeta`, `papers/getCitedRefs`, and `events/publish`. (The host core carries no plugin-domain methods — ADR 0003.) ### Theorems(builtin 插件,read-through 一个 Lean-content git checkout) theorems builtin 插件把上游 Lean-content 仓库(`QATLAS_THEOREMS_DIR`)的 `artifacts/registry.json` + `artifacts/audit_records/certified.json` + Lean 源 read-through 暴露出来。它**不持有** PocketBase collection(ADR 0004:已证 Theorem 是从 git read-through,不落库)。 | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `GET` | `/api/theorems/list` | `theorems:read` | 列已证 Theorem(支持 `?family_id=&audit_status=&kind=` 过滤)| | `GET` | `/api/theorems/families` | `theorems:read` | 列 theorem family 定义(过滤下拉数据源)| | `GET` | `/api/theorems/stats` | `theorems:read` | 聚合计数(by_kind / by_family / by_audit_status / sorry_free / certified)| | `GET` | `/api/theorems/theorem/{fqn}` | `theorems:read` | 取单个 Theorem(完整 registry 条目 + audit verdict)| | `GET` | `/api/theorems/theorem-source/{fqn}` | `theorems:read` | 按需取该 Theorem 的 Lean 源文件 | | `GET` | `/api/theorems/sync/status` | `theorems:read` | theorems git 状态| | `POST` | `/api/theorems/sync/pull` | `theorems:write` | 触发服务端 theorems git fast-forward pull,随后同步 reload 内存缓存 | > `theorems:write` 隐式含 `theorems:read`。`/api//sync/{pull,status}` 是平台为 > pull 类插件挂的**统一形状**(ADR 0002/0003)。 ### PAT 管理(**只接受 session token**,PAT auth 被拒) | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `POST` | `/api/pat` | session only | 创建 PAT,返回明文(一次)| | `GET` | `/api/pat` | session only | 列当前用户的 PAT(无明文)| | `DELETE` | `/api/pat/{id}` | session only | 撤销 | ### OAuth Device Flow(`qatlas auth login` 后端) RFC 8628 device authorization grant。CLI 没有浏览器 / session,所以由用户在浏览器里登录 + 输 user_code 授权,CLI 轮询拿 minted PAT。前两个端点匿名(CLI 调),后三个 **session-only**(PAT auth 被拒,理由同 `/api/pat`:泄露的 PAT 不应能自我繁殖)。 | Method | Path | 鉴权 | 用途 | |---|---|---|---| | `POST` | `/api/oauth/device/code` | 匿名 | RFC 8628 §3.1,CLI 启动 flow。body:`{name, description?, expires_in_days, scopes[]}`(要 mint 的 PAT 规格)。返回 `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}` | | `POST` | `/api/oauth/device/token` | 匿名 | RFC 8628 §3.4 + §3.5。CLI 用 `device_code` 按 `interval` 轮询。成功返回 minted PAT plaintext(一次性);待批 / 太快 / 过期 / 拒绝 / 无效全部返 **HTTP 400** + `{error}` ∈ `authorization_pending \| slow_down \| expired_token \| access_denied \| invalid_grant` | | `GET` | `/api/oauth/device/code?user_code=` | session only | SPA 的 `//device` 用 `user_code` 查待批请求;响应除了 CLI seeded 的 `name` / `scopes` / `expires_in_days` 外还包含 `available_scopes`(用户能 mint 的全部 scope)/ `scope_descriptions` / `max_expiry_days`,供浏览器渲染编辑表单 | | `POST` | `/api/oauth/device/approve` | session only | body `{user_code}` + 可选 `{name, scopes, expires_in_days}` 覆盖 —— 让用户在 Approve 时改 PAT 规格。下一次 `/token` 轮询会以最终(覆盖后的)规格 mint PAT 并绑定到当前 session 用户 | | `POST` | `/api/oauth/device/deny` | session only | body `{user_code}`,拒绝。下一次 `/token` 轮询返回 `access_denied` | > 完整 device-flow 概念背景见 [概念 · 鉴权 · OAuth Device Flow](../concepts/auth-model.md);schema 详见 `/swagger/index.html`。 ### Admin(管理端:Asset Browser + 获取失败) Admin 端点全部走 `adminGuard`:**session token + admin 白名单**(`auth.admin_logins` 等,PAT 一律拒绝——管理员是人)。Asset Browser 是运维通道,**独立于 `paper_access.enabled`**(不分发义务:admin 面板不算对外重分发 lane)。 #### Admin Asset Browser(`/api/admin/assets/*`) SPA 页面 `/$lang/admin/assets`:防抖搜索 → 论文卡片 → 可展开资产表,每行 Preview(PDF viewer / Markdown 与 LaTeX 渲染预览,可切换原文)、Download、Copy Presigned URL + Copy S3 Key。 `{kind}` 取 `pdf` 或 `markdown`(别名 `md`)。 | Method | Path | 用途 | |---|---|---| | `GET` | `/api/admin/assets/{paper_id}` | 资产清单:每个 asset 的 S3 object key / size / sha256 / content-type | | `GET` | `/api/admin/assets/{paper_id}/{kind}` | 单资产详情;`?presign=true&ttl=1h` 附带 presigned URL | | `GET` | `/api/admin/assets/{paper_id}/{kind}/download` | 代理流式下载(`Content-Disposition: attachment`)| | `GET` | `/api/admin/assets/{paper_id}/{kind}/inline` | 代理流式预览(`Content-Disposition: inline`;PDF 进 iframe viewer,Markdown 作为文本获取后由 SPA 安全渲染,保留原文模式)| | `GET` | `/api/admin/assets/{paper_id}/{kind}/url` | presigned S3 URL JSON——浏览器直连 S3,绕过 qatlasd 代理。TTL **1 分钟–24 小时**(默认 1h),超出区间截断 | | `GET` | `/api/admin/assets/batch?paper_ids=a,b,c` | 批量资产清单(**≤50 篇**)| | `GET` | `/api/admin/assets/batch/download?paper_ids=a,b,c&kind=pdf` | 流式 ZIP 打包下载(**≤20 篇**)| | `GET` | `/api/admin/assets/search?q=...&kind=pdf&limit=50` | 按 title / DOI / arXiv ID(ILIKE)找**有资产**的论文 | > presign 仅 S3 后端支持(`presign_supported` 字段);dev 的 LocalStore 回落代理流。 #### 获取失败与补救 | Method | Path | 用途 | |---|---|---| | `GET` | `/api/admin/acquisition/failures` | 持久化的 PDF 抓取失败记录:阶段、原因、重试计数(`paper_acquisition_events` 审计表之上的聚合视图)| 管理员 SPA 的失败表对每行渲染 DOI 直达链接 + **内联 PDF 上传**(人工取回 PDF 对着 DOI 直接 `POST /api/papers/{doi}/upload-pdf`),即时闭合获取状态并触发 MinerU——这是 Robust Downloader 的失败兜底闭环,见 [Robust Downloader · 管理员补救流](downloader.md#管理员补救流失败闭环)。 ## 端点详解:选粹 ### `GET /api/health` 返回 PocketBase envelope 形状: ```json { "code": 200, "message": "API is healthy.", "data": { "status": "healthy", "version": "0.2.8", "uptime_seconds": 12345, "time": "2026-05-29T03:00:00Z", "checks": { "rawstore": { "status": "ok", "backend": "s3", "endpoint": "http://:9000", "bucket": "qatlas-raw", "latency_ms": 12 }, "postgres": { "status": "ok", "backend": "postgres", "latency_ms": 8 } } } } ``` - `data.status` 是聚合状态:`healthy`(全部 ok 或 not_configured)/ `degraded`(任一 error) - `code` **永远 200**(即使 degraded)—— 别让上层 LB / Caddy 把整条链路 trip 成 down - `message` 在 degraded 时变 `"Dependency degraded."`,方便 log scraper - 每个 probe 5 秒超时(`probeTimeout`),三个并行执行 - PostgreSQL / S3 不配置时返回 `"status": "not_configured"`,**不下拉聚合等级** ### `POST /api/papers/{arxiv_id}/upload-pdf` 完整流程详见 [Upload API](upload-api.md)。要点: - multipart form 字段:`pdf` (必) - query 参数:`expected_sha256=` (强烈推荐) / `overwrite=true` - 状态码: - `201 Created` — 写了新对象 - `200 OK` — 全部 unchanged 短路,零写入 - `409 Conflict` — 已冻结别名不同SHA,**即使 overwrite=true**;body含已有/新SHA,禁止替换来源 - `400 Bad Request` — sha256 mismatch / 损坏的 multipart / PDF header 不对等 - 并发安全(S3 conditional PUT `If-None-Match`),多 client 同字节并发只产生 1 个 201 + 其余 200 ### 历史兼容 LRO 示例(旧缓存状态字段) > 以下图/示例保留旧接口的历史状态字段,不作为新不可变完整包/readiness或覆盖写规则。 > 当前PDF按gate鉴权交付,无解析token也可读;正文/图片/figures可202,纯read/status > 核验完整包后state=cached/ready=true才固定source/revision重取。新信封、headers与 > 懒迁移/严格上传请以[论文内容契约](paper-content.md)和[Upload API](upload-api.md)为准。 > YAML paper_access.enabled取代旧环境变量;format=link仅同源认证locator,无NAS presign。 仅在 `QATLAS_PAPER_ACCESS_ENABLED=true` 时注册。两类资源 (`markdown` 与 `pdf`)共享同一套**异步 + 进度可拉**的协议,让 agent 方可以放心并发调用同一篇论文: - 第一次 GET 立即返回,**不阻塞** fetch / convert 完成; - N 个并发调用被 server-side dedupe 成 1 次 fetch + 1 次 convert,所有调用方 观察同一 Job snapshot; - `…/status` 端点是 side-effect-free 的,poll 多少次都不会再触发后端任务。 #### 状态机 ``` cached ────────────────────────────────────────────┐ GET /md ── miss ──┬→ queued ──→ fetching ──→ converting ──→ done ──────────────┤ │ (silent fetch from arxiv.org, then MinerU convert) │ │ │ └→ failed(retryable | fatal | quota_exhausted, │ phase ∈ {error_fetching, error_converting}) │ └→ not_in_arxiv (404) │ 注:这是历史状态图;PDF410停用阶段已被当前固定来源鉴权交付取代。 已存PDF无推理依赖,原件未就绪与完整包ready按新契约区分。 ``` #### 触发 GET(202 / 200 / 404) ```bash curl -i https:///api/papers/quant-ph/9508027v2/markdown \ -H "Authorization: Bearer $QATLAS_TOKEN" # 缓存命中 HTTP/1.1 200 OK Content-Type: text/markdown; charset=utf-8 Content-Length: 76382 …markdown bytes… # 缓存未命中(首次或仍在跑) HTTP/1.1 202 Accepted Operation-Location: /api/papers/quant-ph/9508027v2/markdown/status Retry-After: 5 { "arxiv_id": "quant-ph/9508027v2", "state": "queued", "phase": "fetching_pdf", "pdf_ready": false, "md_ready": false, "submitted_at": "2026-06-05T03:00:00Z", "detail": "conversion in progress", "operation": { "status_url": "/api/papers/quant-ph/9508027v2/markdown/status", "next_poll_after_iso": "2026-06-05T03:00:05Z" } } ``` #### 合规:内容访问开关(`QATLAS_PAPER_ACCESS_ENABLED`) **是否对外分发论文内容**由这个服务端开关决定(合规落点,ADR [0011](../adr/0011-by-id-asset-reads.md)),也决定上面这些 **PAPER_ACCESS** 路由是否注册: | 开关 | pdf | markdown / json | 说明 | |---|---|---|---| | **关**(默认,公共 quantum-atlas.ai) | arxiv 论文 → `arxiv.org` 直链;非 arxiv → 不给 | 不给 | 不再分发;canonical 源自己分发。*(当前实现:端点整体不注册 → 404;arxiv 直链是 ADR 0011 既定 OFF 态,跟踪 [#8](https://github.com/IAI-USTC-Quantum/QuantumAtlas/issues/8))* | | **开**(operator 显式接受衍生作品分发义务) | 发(默认直链,见下) | 发(默认字节流,见下) | 详见 [License & Attribution · 论文访问开关](../about/license-and-attribution.md#论文访问开关-self-hosted) | #### 字节流 vs 直链(`?format=`,ADR 0011) 开关**允许发之后**,**用什么形式发**是与合规无关的传输选择,按 kind 有默认值,并可用 `?format=link|bytes` 逐请求复写: | 资产 | 默认 | 复写 | 说明 | |---|---|---|---| | `markdown` | **字节流** | `?format=link` | MinerU 派生的小文本,默认内联串出 | | `images/zip` | **字节流** | `?format=link` | MinerU 产出的图片打包 zip,显式请求才返回 | 后端无法 presign(dev 的 `LocalStore`)时,`link` 请求自动回落字节流。直链响应形如: ```bash curl -i "https:///api/papers/quant-ph/9508027v2/markdown?format=link" \ -H "Authorization: ******" HTTP/1.1 200 OK Content-Type: application/json { "arxiv_id": "quant-ph/9508027v2", "format": "link", "markdown_url": "https://raw.quantum-atlas.ai/qatlas-pdf/9508/9508027v2.md?X-Amz-…", "expires_in": 86400 } ``` #### 进度查询(status;存储不可用时 503) ```bash curl https:///api/papers/quant-ph/9508027v2/markdown/status \ -H "Authorization: Bearer $QATLAS_TOKEN" ``` 各状态对应 body(关键字段加粗): ```javascript // 已就绪 { "arxiv_id": "quant-ph/9508027v2", "state": "cached", "phase": "ready", "pdf_ready": true, "md_ready": true, "markdown_url": "/api/papers/quant-ph/9508027v2/markdown" } // 正在 fetch PDF { "arxiv_id": "quant-ph/9508027v2", "state": "running", "phase": "fetching_pdf", "pdf_ready": false, "md_ready": false, "submitted_at": "...", "started_at": "...", "fetch": { "started_at": "...", "bytes_received": 1234567, "bytes_total": 4503234, "attempts": 1 } } // PDF 已保存,MinerU 在跑(pdf_ready=true 告诉 agent: PDF 没白下) { "arxiv_id": "quant-ph/9508027v2", "state": "running", "phase": "converting_md", "pdf_ready": true, "md_ready": false, "fetch": {"started_at":"...", "completed_at":"...", "bytes_received":4503234, "sha256":"...", "attempts":1}, "convert": {"started_at":"...", "mineru_task_id":"task-abc", "stage":"running", "polled_count":7}, "queue": {"running_count":4, "max_concurrent":4, "eta_basis":"observed_avg_of_15_jobs", "avg_duration_seconds":180} } // MinerU slot 不够,排队中(agent 看 queue.position / eta_seconds 决定退避) { "arxiv_id": "2501.00010v1", "state": "queued", "phase": "converting_md", "pdf_ready": true, "md_ready": false, "submitted_at": "...", "queue": { "position": 3, "ahead_of_me": 2, "running_count": 4, "max_concurrent": 4, "eta_seconds": 540, "avg_duration_seconds": 180, "eta_basis": "observed_avg_of_18_jobs" } } // MinerU 配额耗尽(PDF 已抓取但不再对外交付;等配额恢复后取 markdown) { "arxiv_id": "quant-ph/9508027v2", "state": "cooldown", "phase": "error_converting", "kind": "daily_limit", "pdf_ready": true, "md_ready": false, "detail": "server quota exhausted: all 3 MinerU API keys have reached today's daily limit — service resumes at 2026-06-06T00:01:00+08:00", "retry_after": 1717603260, "retry_after_iso": "2026-06-06T00:01:00+08:00" } // arxiv 没这篇(fatal,agent 应放弃) { "arxiv_id": "quant-ph/9999999v9", "state": "failed", "phase": "error_fetching", "kind": "fatal", "pdf_ready": false, "md_ready": false, "detail": "fetch from arxiv: arxiv: paper not found" } ``` #### Agent 决策三元组 任何 status 响应都带 `state` + `pdf_ready` + `md_ready`,从这三个字段即可 判断「该不该等」「等什么」: | `state` | `pdf_ready` | `md_ready` | agent 行动 | |---|---|---|---| | `cached` | 是 | 是 | 直接 GET 资源拿字节 | | `queued` / `running` | 否 | 否 | 等 `Retry-After`,再 poll | | `running` | 是 | 否 | 等 MinerU 收尾,之后 GET `/markdown` | | `cooldown` | 是 | 否 | 到 `retry_after_iso` 之前不再 poll | | `failed` `kind=fatal` | 否 | 否 | 永久失败,放弃 | | `failed` `kind=retryable` | 否 / 是 | 否 | 到 `retry_after_iso` 后重试 | #### DOI 入口 `{id_or_doi}` 路径段头部如果匹配 `^10\.\d{4,9}/`(IANA DOI 前缀),server 自动经 OpenAlex 反查 → canonical arxiv id → 走同一套 handler。 ```bash # DOI → arxiv → 同一个 LRO 流程 curl -i https:///api/papers/10.1103/PhysRevLett.103.150502/markdown \ -H "Authorization: Bearer $QATLAS_TOKEN" ``` #### 自动默认值(server 应用的隐式推断) server 接受三类"不完整"的 id 输入,并自动补齐到 canonical 形态: | 输入形态 | 例子 | server 推断 | 默认值出处 | |---|---|---|---| | DOI | `10.1103/PhysRevLett.103.150502` | OpenAlex 反查 → arxiv canonical | OpenAlex API | | 无版本 arxiv id | `0811.3171` / `quant-ph/9508027` | 加 `vN` = latest | `/abs/` HTML `` | | bare old-style(无 category) | `9508027` / `9508027v2` | 加 `category=quant-ph` | `paperassets.DefaultOldStyleCategory`(来自 `cat:quant-ph` bootstrap 假设;issue #10 跟踪全量审计)| 每一次应用默认值,server 都会在响应里通过**两条平行通道**告知 caller: **HTTP headers**(永远存在,包括字节流响应): ``` X-QAtlas-Requested-Id: 9508027 X-QAtlas-Resolved-Id: quant-ph/9508027v2 X-QAtlas-Defaults-Applied: version=v2 (no version specified; latest published version used); category=quant-ph (no category prefix; server default per docs/reference/arxiv-ids.md §3.1) ``` **JSON body 字段**(仅 JSON 响应;与 header 同步): ```javascript { "arxiv_id": "quant-ph/9508027v2", "requested_id": "9508027", "resolved_id": "quant-ph/9508027v2", "defaults_applied": [ "version=v2 (no version specified; latest published version used)", "category=quant-ph (no category prefix; server default per docs/reference/arxiv-ids.md §3.1)" ], "state": "queued", ... } ``` 输入本身就是 canonical(带 vN + 带 category)时,三个字段全部**省略**,body 保持精简。 Agent / client 集成建议: - 把 `X-QAtlas-Defaults-Applied` 渲染成一行 `Note: …` 输出,避免用户因为"我请求的是 0811.3171 但你给我 v3" 产生疑惑; - `requested_id` 跟 `resolved_id` 不一致时可作为缓存键的二次校验; - 不要硬解析 defaults_applied 的字符串内容(它是 human-readable,未来会有微调);要程序化判断"应用了什么默认"请用 `resolved_id != requested_id` 加上 path 前缀启发式。 DOI 路径特有错误: | 状态 | 含义 | |---|---| | `400` | DOI 格式不合规(长度 > 256 / 含控制字符 / 前缀不匹配)| | `404` | OpenAlex 找不到该 DOI,**或**该 work 没有 arxiv 表现(OA `ids.arxiv` 缺失)| | `404` | DOI 解出 arxiv id,但 arxiv abs 页 404(论文被撤稿 / 从未索引)| | `502` | OpenAlex 上游错误 / arxiv version 解析上游错误(连接 / 5xx 重试用尽)| | `503` | `QATLAS_OPENALEX_MAILTO` 未配置 — 服务方需补配 | | `503` | arxiv version 解析 rate-limited — 稍后重试 | ### `POST /api/pat`(session only) ```json { "name": "ci-upload", "description": "...", "scopes": ["papers:write"], "expires_in_days": 365 } ``` 约束: - `name` 必填,≤80 字符 - `description` ≤200 字符 - `expires_in_days` 必填,1–365 - `scopes` 必须是 `/api/pat/scopes` 返回词表里的;空集合也接受(**这个 PAT 啥都干不了**) 响应(**plaintext 只出现这一次**): ```json { "id": "abc123", "name": "ci-upload", "prefix": "qat_AB", "plaintext": "qat_ABXXXXX...XXXXX", "description": "", "scopes": ["papers:write"], "expires_at": "2027-05-29 03:00:00.000Z", "created": "2026-05-29 03:00:00.000Z" } ``` ## 错误响应规范 绝大多数错误都是 `{"detail": ""}`,例外是: | 端点 | 特殊形状 | |---|---| | `upload-pdf` 409 | `{detail, existing_sha256, new_sha256, existing_path, hint}` | | `upload-pdf` 400 (sha256 mismatch) | `{detail, expected_sha256, actual_sha256}` | | `scopeGuard` 403 | `{detail, obj, act}` | | `graph/*` 故障 | **`200 + {error: "..."}`**(不是 5xx)| ## 速率限制 PocketBase 自带 collection 级 throttle。`/api/pat` 还挂了自定义 rate-limit 规则(默认在启动时 `pat.EnsureDefaults` 装),防止暴力 mint。常见限制: - `/api/pat` POST: 10/minute per user - `/api/health`: 无限 - 其他写口: 60/minute per user 具体规则在 PocketBase Settings → `_pb_users_auth_` 等 collection 的 throttle 字段。 ## PocketBase 原生 collection API 除了上面 `/api/*` 自定义 endpoint,PocketBase 自己暴露 `/api/collections//records/...` 的 CRUD API。详见 [PocketBase 文档](https://pocketbase.io/docs/api-records/)。**业务上几乎不用**——所有暴露给用户的能力都通过自定义 `/api/...` endpoint 走。 例外:SPA 直接用 PocketBase JS SDK 做 OAuth 登录、读 users 自身记录等。 ### 资产读取与存储故障 注册表的 `has_md` / `has_pdf` 及资产路径表示已登记的内容,不是实时存储健康探针。 Markdown 资产读取,以及单篇 PDF/Markdown status 的存储探测最多等待 10 秒;更短的请求 deadline 优先。存储 Stat、Get 或首字节读取失败时返回 HTTP 503、`Retry-After: 5` 和以下结构,详细后端错误留在服务端日志: ```json {"code":"asset_store_unavailable","detail":"asset storage temporarily unavailable; retry shortly","kind":"retryable","retryable":true,"state":"unavailable"} ``` 失败不计为缓存 miss,不启动替代下载或 MinerU 转换,不清空登记资产。批量 status 保持逐项结果:受影响项的 `state=unavailable` 并携带 `error`,就绪布尔值不能单独 解释为资产缺失。流已开始后发生的失败只能中断传输,客户端应丢弃不完整内容并重试。 旧 `paper_assets` 不会随升级自动回填为不可变 source / parse revision;原始资产 路径保留,列出 originals 只读,可能得到空列表。需要固定版本时按贡献流程登记 实际 PDF 和解析产物,并核对 source/revision ID 与 SHA-256。