1. 基本约定
1.1 地址与版本
所有接口都以 /api/v1 开头,例如 http://nas.local:3000/api/v1/library。
/api/v1保证向后兼容:只会新增字段或接口,不会删除或改变已有字段的含义。客户端应忽略不认识的字段。- 不兼容的改动会放到
/api/v2,/api/v1继续保留。 - 网页版使用的
/api/...(不带版本)与/api/v1/...是同一组接口,第三方客户端请用带版本的地址。
从外网访问 NAS 时,请通过反向代理(Nginx、Caddy、群晖反代等)启用 HTTPS,否则密码与令牌会以明文传输。使用反向代理时请同时设置服务器环境变量
TRUST_PROXY(见 README),否则所有客户端会被当作同一个 IP 限流。
1.2 请求
- 请求体为 JSON:
Content-Type: application/json。没有请求体的 POST 也请发送{}。 - 除特别注明外,接口都需要登录:请求头
Authorization: Bearer <accessToken>。
1.3 响应格式
成功:
{
"success": true,
"code": "OK",
"message": "操作成功",
"data": { },
"meta": { "requestId": "…", "timestamp": "2026-11-05T07:40:25.889Z" }
}
分页接口另有 pagination:{ "page": 1, "pageSize": 20, "total": 135, "totalPages": 7 }。
失败时 success 为 false,message 是可直接显示给用户的中文说明:
| HTTP 状态 | code | 含义 |
|---|---|---|
| 401 | AUTH_UNAUTHORIZED |
未登录、令牌无效或已过期 |
| 403 | AUTH_FORBIDDEN |
没有权限(如普通用户调用管理员接口) |
| 403 | AUTH_PASSWORD_CHANGE_REQUIRED |
账号仍在使用默认密码,需先调用改密码接口(见第 2 节) |
| 404 | RESOURCE_NOT_FOUND |
书籍、书签等不存在 |
| 409 | BUSINESS_CONFLICT |
状态冲突(如书已在书架中) |
| 415 | UNSUPPORTED_MEDIA_TYPE |
不支持的资源类型 |
| 422 | VALIDATION_ERROR |
参数校验失败 |
| 429 | RATE_LIMIT |
请求过于频繁 |
| 500 | INTERNAL_ERROR |
服务器内部错误 |
1.4 时间格式
数据库中的时间(imported_at、added_at、updated_at 等)格式为 YYYY-MM-DD HH:MM:SS,时区为 UTC(不带 Z)。meta.timestamp 为 ISO 8601。
1.5 跨域
原生 App 不受浏览器跨域限制,无需配置。若客户端运行在浏览器中(另一个域名下的网页),需在服务器环境变量 CORS_ORIGINS 中加入该来源,多个用逗号分隔,例如 CORS_ORIGINS=https://reader.example.com,http://localhost:5173。
2. 登录与令牌
使用用户名、密码登录,得到两个令牌:
accessToken:有效期 2 小时,放在每个请求的Authorization头里。refreshToken:有效期 30 天,只用于换取新令牌。请妥善保存(如系统钥匙串)。
推荐流程:请求返回 401 时,用 refreshToken 调用续期接口拿到新令牌,再重试原请求;续期也返回 401 时,回到登录界面。用户修改密码或被删除后,旧令牌立即失效。
频率限制(按客户端 IP,超出返回 429 RATE_LIMIT):
- 登录、改密码:15 分钟内最多 20 次失败,成功的请求不计。
- 注册:15 分钟内最多 20 次(成功的也计数)。
- 续期:15 分钟内最多 120 次。请在收到 401 时续期,或在 accessToken 快过期时续期,不要每次请求前都续期。
POST /api/v1/auth/login(无需登录)
{ "username": "admin", "password": "******" }
返回:
{
"user": { "id": "b56e…", "username": "admin", "role": "admin" },
"accessToken": "eyJ…",
"refreshToken": "eyJ…"
}
用户名或密码错误时返回 401。
user.mustChangePassword 为 true 表示账号仍在使用默认密码(如首次启动自动创建的管理员)。这时除 me、password、logout 外,其他接口都返回 403 AUTH_PASSWORD_CHANGE_REQUIRED,客户端应先引导用户修改密码。
POST /api/v1/auth/refresh(无需登录)
{ "refreshToken": "eyJ…" }
返回新的 { "accessToken", "refreshToken" }。两个都要替换保存。
GET /api/v1/auth/me
返回当前用户 { id, username, role, created_at, mustChangePassword }。
POST /api/v1/auth/password
修改自己的密码,需要默认密码的账号也可以调用。
{ "currentPassword": "当前密码", "newPassword": "Good#Pass1" }
新密码规则:8–100 位,包含大写字母、小写字母、数字和特殊字符(如 !@#$%^&*),且不能与当前密码相同。当前密码错误或新密码不合规时返回 422,message 说明原因。
成功后返回新的 { user, accessToken, refreshToken },请替换保存。此账号在其他设备上的令牌全部失效,需要重新登录。
POST /api/v1/auth/logout
服务器不保存会话,调用后客户端删除本地保存的令牌即可。
POST /api/v1/auth/register(无需登录)
{ "username": "reader", "password": "至少 6 位", "inviteCode": "ABCD2345" }
用户名 3–50 位,密码 6–100 位。站点为邀请注册模式时必须提供 inviteCode。成功返回 201,内容与登录相同。
3. 站点信息
GET /api/v1/settings/public(无需登录)
{ "site_name": "我的书房", "site_subtitle": "", "site_theme": "indigo" }
可用于登录前确认服务器地址填写正确。
系统更新(管理员)
以下三个接口只有管理员可用,普通用户返回 403。站点会定期读取远端 CHANGELOG,比较本站版本,有新版本时提醒管理员。
Release 结构:
{
"version": "1.1.0",
"date": "2026-11-10",
"intro": "",
"sections": [
{ "title": "新增", "kind": "added", "items": [{ "text": "……", "actionRequired": false }] }
]
}
kind 为 added(新增)、fixed(修复)或 other;actionRequired 为 true 表示条目以「【需操作】」开头,升级时需要管理员手动处理。version 是不带后缀的 X.Y.Z,date 可能为 null。
GET /api/v1/settings/update-status
返回最近一次检查的结果,不联网:
{
"currentVersion": "1.0.0",
"branch": "main",
"enabled": true,
"noticeEnabled": true,
"envDisabled": false,
"checkedAt": "2026-11-06T08:00:00.000Z",
"error": null,
"newer": [ /* Release,新版本在前,只含比本站新的版本 */ ]
}
currentVersion 取自本站 CHANGELOG,读不到或其中没有本版本线的版本节时为 null(此时 newer 为远端全部版本,按版本降序);enabled 为 false 表示用环境变量 UPDATE_CHECK=off 关闭了更新检查(此时 envDisabled 为 true);noticeEnabled 为「系统提醒」开关的实际状态(环境变量关闭时恒为 false),关闭时前端隐藏铃铛、后台停止定时检查;error 是最近一次检查失败的原因(失败时 newer 仍保留上次成功的结果)。
POST /api/v1/settings/update-check
立即联网检查一次,返回值与 update-status 相同。检查失败也返回 200,原因在 error 里。「系统提醒」关闭(noticeEnabled 为 false,含环境变量关闭)时不联网,直接返回当前状态。
「系统提醒」开关通过 PUT /api/v1/settings 保存,键 system_notice_enabled,值 'true' / 'false'(缺省视为开启)。保存后立即生效:关闭时清除后台定时检查;开启时恢复定时检查,并在约 5 秒后检查一次。
GET /api/v1/settings/changelog
本站 CHANGELOG 中最近 5 个版本:
{ "currentVersion": "1.0.0", "missing": false, "releases": [ /* Release */ ] }
missing 为 true 表示本站找不到 CHANGELOG 文件。
HTTPS 证书(管理员)
使用自己的证书直接提供 HTTPS(服务器不申请、不续签证书)。需要先打开「启用 HTTPS」开关(见下方 PUT /settings/https/enabled,默认关闭);证书也可以不经接口、直接放进数据目录的 certs/(fullchain.pem + privkey.pem),开关打开时替换后自动生效。开关关闭时上传的证书会被保存,但要等打开开关后才生效。接口只返回证书的元数据,从不返回私钥。
状态对象:
{
"enabled": true,
"listening": true, "port": 3443,
"cert": { "domains": ["book.example.com"], "issuer": "C=US, O=Let's Encrypt, CN=R11",
"notBefore": "2026-11-01T00:00:00.000Z", "notAfter": "2027-01-30T00:00:00.000Z",
"expired": false, "notYetValid": false, "daysLeft": 83 },
"lastError": null,
"warning": null
}
enabled:设置页「启用 HTTPS」开关(默认关闭)。关闭时不提供 HTTPS(即使certs/里有证书),也没有到期提醒。listening:HTTPS 是否在port(环境变量HTTPS_PORT,默认 3443)上提供服务;cert为当前生效的证书,未开启时为null。lastError:最近一次加载证书的问题(「缺少证书文件」「无法解析证书」「无法解析私钥」「证书与私钥不匹配」「证书无法用于 HTTPS」(如密钥太短)「HTTPS 端口 N 被占用」)。新证书有问题时会继续使用上一张可用证书,此时listening仍为true。warning:到期提醒,证书剩余 14 天内为「HTTPS 证书将在 N 天后到期(域名)」,过期后为「HTTPS 证书已过期(域名)」,否则null。过期证书仍会继续使用。
GET /api/v1/settings/https
返回状态对象。
PUT /api/v1/settings/https
上传证书(含证书链)与私钥,PEM 文本,各不超过 65536 字符:
{ "cert": "-----BEGIN CERTIFICATE-----\n…", "key": "-----BEGIN PRIVATE KEY-----\n…" }
校验通过后写入 certs/ 并立即生效,返回状态对象并附 warnings(如 ["证书已过期"]、["证书尚未生效"],此类证书仍会被接受)。无法解析、证书与私钥不匹配或证书无法用于 HTTPS 时返回 422 VALIDATION_ERROR,message 为具体原因,原有证书保持不变;数据目录不可写等写入失败返回 500。
PUT /api/v1/settings/https/enabled
打开或关闭「启用 HTTPS」开关:{ "enabled": true },返回状态对象。关闭时证书文件保留,下次打开直接生效。
DELETE /api/v1/settings/https
删除证书并关闭 HTTPS,返回新的状态对象。
4. 书库
GET /api/v1/library
分页列出书籍。
| 参数 | 说明 |
|---|---|
page |
页码,从 1 开始,默认 1 |
pageSize |
每页数量,1–100,默认 20 |
search |
按书名或作者模糊搜索 |
category |
按分类筛选(完全匹配) |
sortBy |
imported_at(默认)、title、author、file_size、category、rating |
sortOrder |
desc(默认)或 asc |
series_grouped |
为 1 时不列出属于某个系列的书(系列见下文) |
data 是书籍数组,每本书的主要字段:
| 字段 | 说明 |
|---|---|
id |
书籍 ID(UUID),后续接口都用它 |
title |
原始书名(来自文件名或元数据) |
display_title |
用于显示的书名(去掉了版本标记、作者等),界面请优先用它 |
author |
作者,可能为 null |
file_format |
txt / epub / pdf / umd |
cover_url |
封面地址,可能为 null,见「封面」 |
summary、category、tags |
简介、分类、标签(tags 是 JSON 数组字符串) |
is_finished |
是否完结(0 / 1) |
chapter_count |
章节数(扫描时统计,可能为空) |
file_size |
文件大小(字节) |
rating |
评分,可能为 null |
series_id |
所属系列 ID,没有为 null |
imported_at |
入库时间 |
GET /api/v1/library/:id
单本书的详情,字段同上。
GET /api/v1/library/series
所有系列(同一部作品分成多个文件,如上下册):[{ id, name, summary, cover_url, member_count, … }]。
GET /api/v1/library/series/:id
{ series, members },members 为该系列的书籍数组。
封面
cover_url 有两种形式:
- 以
/开头的站内路径(如/covers/<id>.jpg):拼上服务器地址即可,无需登录。 - 完整的
http(s)://地址:直接使用。
cover_url 为 null 时,客户端自行显示默认封面。
5. 书架与阅读历史
每个用户有自己的书架。
GET /api/v1/shelf
按最近阅读排序的书架列表:
[{
"book_id": "4807…", "added_at": "2026-05-06 08:43:08", "last_read_at": "2026-11-05 04:15:19",
"title": "西游记", "display_title": "西游记", "author": "吴承恩",
"cover_url": "/covers/4807….jpg", "category": "玄幻", "file_format": "umd",
"chapter_index": 12, "chapter_title": "第十三回 …", "scroll_top": 0,
"progress_updated_at": "2026-11-05 04:15:19"
}]
没有阅读进度时,chapter_index 等进度字段为 null。
GET /api/v1/shelf/:bookId
{ "inShelf": true }
POST /api/v1/shelf
{ "bookId": "4807…" }
成功返回 201;已在书架中返回 409。
DELETE /api/v1/shelf/:bookId
不在书架中时返回 404。
阅读历史
和书架不同,阅读历史不用手动添加:客户端每次打开一本书阅读时调用一次「记一次打开」,历史按最近打开排序。历史只记打开时间,移除、清空历史都不影响阅读进度和书架。
GET /api/v1/history
参数 page(默认 1)、pageSize(默认 30,最大 100),分页格式见 1.3。最近打开的在前:
[{
"book_id": "4807…", "opened_at": "2026-11-10 07:41:05.123",
"title": "西游记", "display_title": "西游记", "author": "吴承恩",
"cover_url": "/covers/4807….jpg", "category": "玄幻", "file_format": "umd",
"chapter_index": 12, "chapter_title": "第十三回 …", "progress_updated_at": "2026-11-05 04:15:19"
}]
opened_at 精确到毫秒(UTC)。没有阅读进度时,chapter_index 等进度字段为 null;已从书库删除的书不列出。
POST /api/v1/history/:bookId
记一次打开(已在历史中则刷新打开时间、排到最前)。书不存在返回 404。
DELETE /api/v1/history/:bookId
从历史移除一本;不在历史中返回 404。
DELETE /api/v1/history
清空自己的阅读历史,返回 { "removed": 12 }。
6. 阅读
GET /api/v1/reader/:bookId/chapters
目录:
{
"format": "epub",
"chapters": [
{ "index": 0, "title": "封面", "level": 0 },
{ "index": 1, "title": "第一章 …", "level": 1, "groupTitle": "第一部 小丑" }
]
}
| 字段 | 说明 |
|---|---|
index |
章节序号,从 0 开始,取内容、保存进度都用它 |
title |
章节标题,可能为空字符串(如漫画的单页) |
level |
可选。目录层级,0 为顶层;没有多层目录的书不返回 |
groupTitle |
可选。从这一章开始的分组标题(如 TXT 的「第一部 小丑」)。分组本身不是章节,显示为比该章高一层的目录项 |
href |
可选。EPUB 内部路径,客户端一般用不到 |
PDF 另有 pdfUsePlugin:为 false 时 chapters 为空,表示管理员设置为直接显示原文件,请用下文的「原文件」接口下载 PDF 自行渲染。
GET /api/v1/reader/:bookId/chapter/:index
章节内容。参数 format:
html(默认):返回{ "index": 1, "content": "<p>…</p>" },是网页版使用的、已清理过的 HTML。blocks:返回结构化内容块,推荐 App 使用:
{
"index": 8,
"format": "blocks",
"blocks": [
{ "type": "h", "level": 4, "text": "摆平违规者", "anchor": 1 },
{ "type": "p", "text": "清朝的京官比外官穷……", "anchor": 3 },
{ "type": "img", "src": "/api/reader/ee17…/asset/OEBPS/Images/1.jpg", "alt": "插图", "anchor": 7 }
]
}
| type | 字段 | 说明 |
|---|---|---|
h |
level、text |
标题,level 为 1–6 |
p |
text |
段落。已去掉首行缩进,由客户端自行排版;text 中的 \n 是原文的换行(如诗句) |
img |
src、alt(可选) |
图片,src 规则见「图片」 |
每个块都有 anchor,用于保存阅读进度(见下文)。
- TXT 的章节标题也包含在正文第一段中,与网页版一致。
- 漫画 EPUB 的每一页通常只有一个
img块。
format 不是 html 或 blocks 时返回 422。
图片
章节中的图片地址形如 /api/reader/<bookId>/asset/<路径>,是相对服务器的地址,拼上服务器地址即可。图片请求无需登录,可直接交给图片加载库。只提供 JPEG、PNG、GIF、WebP。
GET /api/v1/reader/:bookId/raw
下载原文件(需要登录),可用于离线阅读或自行渲染 PDF。Content-Type 为 application/pdf、application/epub+zip 或 application/octet-stream。
7. 阅读进度
进度按用户、按书保存,网页版与各客户端共用,读到哪里在所有设备上同步。
GET /api/v1/reader/last-read
最近读的书:当前用户最近一次更新阅读进度、且书仍在书库中的那本书(不判断是否读完)。返回 { "bookId": "…" };没有读过任何书时 data 为 null。网页版「安装到桌面」后从桌面图标打开时用它进入上次读的书。
网页清单:
GET /manifest.webmanifest(无需登录,不在/api下)返回 PWA 清单,name取网站名称(未设置时为「简单书房」)。
GET /api/v1/reader/:bookId/progress
{
"hasProgress": true,
"chapterIndex": 12,
"chapterTitle": "第十三回 …",
"scrollTop": 0,
"paragraphIndex": 37,
"paragraphOffset": 0.4,
"updatedAt": "2026-11-05 04:15:19"
}
没有进度时 hasProgress 为 false,其余字段为 0 或空。
POST /api/v1/reader/:bookId/progress
{ "chapterIndex": 12, "chapterTitle": "第十三回 …", "scrollTop": 0, "paragraphIndex": 37, "paragraphOffset": 0.4 }
| 字段 | 说明 |
|---|---|
chapterIndex |
必填,章节序号 |
scrollTop |
必填,网页版的滚动像素,App 填 0 即可 |
chapterTitle |
可选,最长 100 字,用于书架显示「读到」 |
paragraphIndex |
可选,当前屏幕顶部那个块的 anchor(不是块在数组中的位置) |
paragraphOffset |
可选,0–1,该块已经滚过的比例 |
位置的含义:
- 保存时,
paragraphIndex填屏幕顶部第一个可见块的anchor。anchor与网页版的段落计数一致,所以 App 与网页版之间可以精确接着读。 - 恢复时,找到第一个
anchor >= paragraphIndex的块,滚动到该块,再按paragraphOffset向下偏移块高度的相应比例。 - 后保存的覆盖先保存的。离开阅读界面或翻章时保存一次,阅读中建议停止滚动约 1 秒后保存,不要每帧都保存。
保存时书若在书架中,书架的「最近阅读」时间也会更新。
8. 书签
每本书最多保留最近的 10 个书签,超出时自动删除最早的。
GET /api/v1/reader/:bookId/bookmarks
[{ id, book_id, chapter_index, scroll_top, note, created_at }],按创建时间倒序。
POST /api/v1/reader/:bookId/bookmarks
{ "chapterIndex": 12, "scrollTop": 0, "note": "最长 500 字,可选" }
成功返回 201 和新书签。
DELETE /api/v1/reader/:bookId/bookmarks/:id
9. 阅读偏好
每个用户一份,由客户端自行定义内容(字号、主题等),网页版也存在这里。
GET /api/v1/settings/reader-prefs
返回保存的对象,没有时为 null。
PUT /api/v1/settings/reader-prefs
请求体是扁平的对象:值只能是字符串(最长 200)、数字或布尔值,最多 30 个字段,总大小不超过 4KB。整个对象会替换原值。
和网页版共用这份设置时,请先读出原有内容,修改后整体写回,保留不认识的字段。
10. 最小示例
BASE=http://nas.local:3000/api/v1
# 登录
TOKEN=$(curl -s -X POST $BASE/auth/login -H 'Content-Type: application/json' \
-d '{"username":"reader","password":"******"}' | jq -r .data.accessToken)
# 书架、目录、第一章内容
curl -s $BASE/shelf -H "Authorization: Bearer $TOKEN"
curl -s $BASE/reader/<bookId>/chapters -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/reader/<bookId>/chapter/0?format=blocks" -H "Authorization: Bearer $TOKEN"
# 保存进度
curl -s -X POST $BASE/reader/<bookId>/progress -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"chapterIndex":0,"scrollTop":0,"paragraphIndex":5}'