Jelu 0.88.2 常用 CLI 命令与工具详解

Jelu 0.88.2 日常命令与工具实测手册——systemd 服务管理、java 前台启动与工作目录要点、REST API 三种认证与书目/阅读事件/统计/导入导出全链路 curl 实测、sqlite3 库内检查、calibre fetch-ebook-metadata 在线抓取、在线备份与恢复演练,全部命令附真实回显。

JeluJeluUbuntu24.04Java平台NAS 自托管2026/10/28

Jelu 0.88.2 常用 CLI 命令与工具详解

Jelu 本体是一个 Spring Boot fat jar(jelu-0.88.2.jar),没有自带的「jelu start」这类多子命令 CLI。日常运维实际围绕六类工具展开:systemd 服务管理、java 直接启动、REST API(curl)、sqlite3 数据库检查、calibre 的 fetch-ebook-metadata 元数据抓取,以及基于 SQLite 备份的恢复流程。本文在 Ubuntu 24.04 + Jelu 0.88.2 环境中把六类工具逐条实测,所有命令与回显均为真实执行结果,可直接照做。

一、环境信息

本章列出实测环境与关键路径,文中所有命令均在此环境验证。先确认 Jelu 服务与 Java 版本:

java -version

实测输出:

openjdk version "21.0.12.1" 2026-08-18
OpenJDK Runtime Environment (build 21.0.12.1+1-1-24.04.4-Ubuntu)
OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-24.04.4-Ubuntu, mixed mode, sharing)
systemctl is-active jelu

实测输出:

active
项目值
操作系统Ubuntu 24.04
Jelu 版本0.88.2(jar:/opt/jelu/jelu-0.88.2.jar,108216402 字节)
JavaOpenJDK 21.0.12.1(Ubuntu 仓库 openjdk-21)
SQLite CLI3.45.1(Ubuntu 仓库 sqlite3 3.45.1-1ubuntu2.8)
calibre7.6.0+ds-1build1(提供 fetch-ebook-metadata)
工作目录/var/lib/jelu(与 systemd 单元 WorkingDirectory 一致)
数据目录/var/lib/jelu(数据库 jelu.db、日志 jelu.log、导入导出 files/imports/)
服务端口11111
演示管理员账号admin / q1w2e3r4
服务单元/etc/systemd/system/jelu.service

/var/lib/jelu 实际内容(后续各节会反复用到这些文件):

ls -la /var/lib/jelu/

实测输出(节选):

drwxr-xr-x 7 jelu jelu   4096 Oct 28 11:24 .
-rw-r--r-- 1 jelu jelu    301 Oct 28 11:09 application.yml
-rw-r--r-- 1 jelu jelu 417792 Oct 28 11:24 jelu.db
-rw-r--r-- 1 jelu jelu 263908 Oct 28 11:24 jelu.log
drwxr-xr-x 4 jelu jelu   4096 Oct 28 08:14 files
drwxr-xr-x 4 jelu jelu   4096 Oct 28 11:19 tmp
-rw-r--r-- 1 jelu jelu      0 Oct 28 08:18 write.lock

关键差异解读:application.yml 是外置配置文件(C1 篇详解其逐项参数);jelu.db 是 SQLite 数据库单文件;write.lock 是 Jelu 在当前工作目录创建的锁文件,这是后文「java 直接启动」排障的关键线索。

二、工具概览

Jelu 无自带 CLI 子命令,以下六类工具覆盖它的全部命令行运维场景:

工具负责什么典型场景
systemd(systemctl/journalctl)服务生命周期与日志启停重启、开机自启、看运行日志
java -jar 直接启动绕开 systemd 前台调试验证参数、看完整控制台输出
REST API(curl)全部业务操作书目、阅读事件、统计、导入导出、账号管理
sqlite3库内直查与备份查表结构、核对数据、在线备份
fetch-ebook-metadata(calibre)ISBN/书名在线抓元数据手动补全元数据、验证元数据源可用性
SQLite 备份恢复数据保护.backup 在线备份 + 停服回灌恢复

选型解读:日常操作走 systemd + API 两类;排查启动问题用 java -jar 前台跑;数据层面的核对与保护用 sqlite3 与备份恢复。fetch-ebook-metadata 是 calibre 套件工具,Jelu 的元数据抓取默认就调用它(配置见 C1 篇)。

三、前置条件

  1. Jelu 已按 B1 篇完成安装并由 systemd 托管(systemctl is-active jelu 为 active)。
  2. 已安装 java(上文 java -version 回显)与 curl。
  3. sqlite3 与 calibre 确认已装(本文环境实测回显):
dpkg -l sqlite3 | tail -1

实测输出:

ii  sqlite3        3.45.1-1ubuntu2.8 amd64        Command line interface for SQLite 3
dpkg -l calibre | tail -1

实测输出:

ii  calibre        7.6.0+ds-1build1 all          powerful and easy to use e-book manager
which fetch-ebook-metadata

实测输出:

/usr/bin/fetch-ebook-metadata

若 which 无输出,先安装 calibre(apt install calibre)再继续。

四、命令与工具详解

4.1 systemd 服务管理

Jelu 的标准运行形态是 systemd 服务 jelu.service,单元文件内容如下(cat /etc/systemd/system/jelu.service 实测):

[Unit]
Description=Jelu self-hosted reading tracker
After=network.target

[Service]
Type=simple
User=jelu
Group=jelu
WorkingDirectory=/var/lib/jelu
ExecStart=/usr/bin/java -Xms256m -Xmx512m -Djava.io.tmpdir=/var/lib/jelu/tmp -jar /opt/jelu/jelu-0.88.2.jar
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

逐项说明:User=jelu 以专用低权用户运行;WorkingDirectory=/var/lib/jelu 决定了外部配置 application.yml、write.lock 的位置(4.2 节会看到它有多关键);-Xms256m -Xmx512m 把 JVM 堆限在 256–512MB;Restart=on-failure + RestartSec=5 表示异常退出 5 秒后自动拉起。

状态与自启检查:

systemctl is-active jelu

实测输出:

active
systemctl is-enabled jelu

实测输出:

enabled
systemctl status jelu --no-pager -l

实测输出(节选,省略与主机路径相关的 CGroup 一行):

● jelu.service - Jelu self-hosted reading tracker
     Loaded: loaded (/etc/systemd/system/jelu.service; enabled; preset: enabled)
     Active: active (running) since Wed 2026-10-28 11:09:21 UTC; 22min ago
   Main PID: 6106 (java)
      Tasks: 43 (limit: 2932)
     Memory: 568.2M (peak: 637.7M)
        CPU: 25.765s
             └─6106 /usr/bin/java -Xms256m -Xmx512m -Djava.io.tmpdir=/var/lib/jelu/tmp -jar /opt/jelu/jelu-0.88.2.jar

服务重启与就绪等待(实测:重启后立即访问会拿到 HTTP 000——连接拒绝,服务尚未就绪;约 8 秒后恢复 200):

systemctl restart jelu
systemctl is-active jelu
curl -s -o /dev/null -w "=> HTTP %{http_code}\n" -m 3 http://127.0.0.1:11111/api/v1/setup/status

实测输出:

active
=> HTTP 000

等待约 8 秒后再测:

sleep 8
curl -s -o /dev/null -w "=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/setup/status

实测输出:

=> HTTP 200

停止与启动(停止后 is-active 变为 failed,端口探测 000;启动后恢复):

systemctl stop jelu
systemctl is-active jelu
curl -s -o /dev/null -w "=> HTTP %{http_code}\n" -m 3 http://127.0.0.1:11111/api/v1/setup/status

实测输出:

failed
=> HTTP 000
systemctl start jelu
systemctl is-active jelu

实测输出:

active

日志查看(jelu 的运行日志同时落在 /var/lib/jelu/jelu.log,journal 里也有同一份):

journalctl -u jelu -n 4 --no-pager

实测输出(节选,每行行尾截断):

Oct 28 11:44:35 jelu-lab java[7231]: 2026-10-28T11:44:35.669Z  INFO 7231 --- [io-11111-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initializati
Oct 28 11:44:35 jelu-lab java[7231]: 2026-10-28T11:44:35.676Z DEBUG 7231 --- [io-11111-exec-1] o.s.security.web.FilterChainProxy        : Securing GET /api/v1/s
Oct 28 11:44:35 jelu-lab java[7231]: 2026-10-28T11:44:35.681Z DEBUG 7231 --- [io-11111-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHol
Oct 28 11:44:35 jelu-lab java[7231]: 2026-10-28T11:44:35.682Z DEBUG 7231 --- [io-11111-exec-1] o.s.security.web.FilterChainProxy        : Secured GET /api/v1/se

跟踪实时日志用 journalctl -u jelu -f(Ctrl+C 退出)。

4.2 java 直接启动(前台调试)

绕开 systemd 前台运行 jar,可以看到完整控制台输出,是验证启动参数的第一手段。但直接启动有一个必须遵守的前提:必须先 cd /var/lib/jelu(与 systemd 的 WorkingDirectory 一致)。先看错误示范——在当前目录为 / 时直接启动:

java -Xms256m -Xmx512m -Djava.io.tmpdir=/var/lib/jelu/tmp -jar /opt/jelu/jelu-0.88.2.jar

实测输出(节选,保留关键行):

2026-10-28T11:40:59.046Z  INFO 6675 --- [           main] i.github.bayang.jelu.JeluApplicationKt   : Starting JeluApplicationKt v0.88.2 using Java 21.0.12.1 with PID 6675 (/opt/jelu/jelu-0.88.2.jar started by jelu in /)
2026-10-28T11:41:05.754Z  INFO 6675 --- [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 11111 (http) with context path '/'
2026-10-28T11:41:05.934Z  WARN 6675 --- [           main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: java.lang.reflect.UndeclaredThrowable
2026-10-28T11:41:05.955Z ERROR 6675 --- [           main] o.s.b.c.p.m.PropertiesMigrationListener  : 
The use of configuration keys that are no longer supported was found in the environment:
	Key: spring.mvc.throw-exception-if-no-handler-found
		Line: 25
		Reason: DispatcherServlet property is deprecated for removal and should no longer need to be configured.
2026-10-28T11:41:05.956Z ERROR 6675 --- [           main] o.s.boot.SpringApplication               : Application run failed
Caused by: java.nio.file.NoSuchFileException: /write.lock

端口虽短暂监听(Tomcat started 一行已出现),但上下文随后初始化失败、应用退出,服务不可用:

curl -s -o /dev/null -w "=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/setup/status

实测输出:

=> HTTP 000

排障结论:Jelu 启动时要在当前工作目录创建 write.lock,从 / 启动时无权在该目录创建文件,抛出 NoSuchFileException: /write.lock 后整个应用启动失败;同时工作目录不对,Spring Boot 也加载不到 /var/lib/jelu/application.yml 外置配置。⚠️ 所以 java -jar 直接启动前,务必先切换到服务工作目录。

正确姿势(先 cd 再启动):

cd /var/lib/jelu
java -Xms256m -Xmx512m -Djava.io.tmpdir=/var/lib/jelu/tmp -jar /opt/jelu/jelu-0.88.2.jar

实测输出(节选):

2026-10-28T11:42:54.914Z  INFO 6997 --- [           main] i.github.bayang.jelu.JeluApplicationKt   : Starting JeluApplicationKt v0.88.2 using Java 21.0.12.1 with PID 6997 (/opt/jelu/jelu-0.88.2.jar started by jelu in /var/lib/jelu)
2026-10-28T11:43:00.452Z  INFO 6997 --- [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 11111 (http) with context path '/'
2026-10-28T11:43:00.631Z  INFO 6997 --- [           main] i.github.bayang.jelu.JeluApplicationKt   : Started JeluApplicationKt in 6.138 seconds (process running for 6.569)

验证可用后 Ctrl+C(或另开终端 kill <PID>)停止,再用指定端口参数验证参数生效:

cd /var/lib/jelu
java -Xms256m -Xmx512m -Djava.io.tmpdir=/var/lib/jelu/tmp -jar /opt/jelu/jelu-0.88.2.jar --server.port=11113

实测输出(节选):

2026-10-28T11:43:12.697Z  INFO 7078 --- [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 11113 (http) with context path '/'
2026-10-28T11:43:12.884Z  INFO 7078 --- [           main] i.github.bayang.jelu.JeluApplicationKt   : Started JeluApplicationKt in 6.252 seconds (process running for 6.655)

另开终端验证:11113 端口正常响应、默认端口 11111 已无服务:

curl -s -o /dev/null -w "11113 => HTTP %{http_code}\n" http://127.0.0.1:11113/api/v1/setup/status
curl -s -o /dev/null -w "11111 => HTTP %{http_code}\n" -m 3 http://127.0.0.1:11111/api/v1/setup/status

实测输出:

11113 => HTTP 200
11111 => HTTP 000

要点提示:前台调试与 systemd 服务二选一,同时运行会撞端口与 SQLite/Lucene 锁。-Xms256m -Xmx512m 与 jelu.service 保持一致;-Djava.io.tmpdir 指向可执行目录(若落在 noexec 挂载的 /tmp 上,sqlite-jdbc 原生库会加载失败,排障实录见 B1 篇常见问题)。完整的启动参数、配置覆盖顺序与环境变量(SERVER_PORT、JELU_DATABASE_PATH 等)实测见 C1 篇。

4.3 REST API(curl)

Jelu 全部业务能力都暴露在 /api/v1 前缀的 REST API 上,完整的接口清单可打开 OpenAPI 文档 http://127.0.0.1:11111/v3/api-docs(实测 HTTP 200,返回体开头为 {"openapi":"3.1.0","info":{"title":"Jelu API","license":{"name":"MIT","url":"https://github.com/bayang/jelu/blob/main/LICENSE"},"version":"v1.0"},...},输出节选)。本节按「认证 → 个人 API 令牌 → 书目与阅读 → 统计与书评 → 导入导出 → 账号管理 → 元数据抓取」顺序实测。

(1)三种认证形态:Basic、X-Auth-Token、Bearer 个人令牌

最直接的是 HTTP Basic 认证,用它换取会话令牌:

curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/token

实测输出:

{"token":"004035b3-021b-458d-b946-17893cabfcae"}
=> HTTP 200

拿到的 token 放进 X-Auth-Token 请求头即可访问数据端点(以书目与统计为例):

TOK=$(curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/token | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")
curl -s -H "X-Auth-Token: $TOK" http://127.0.0.1:11111/api/v1/books | head -c 200; echo
curl -s -w "\n=> HTTP %{http_code}\n" -H "X-Auth-Token: $TOK" http://127.0.0.1:11111/api/v1/stats

实测输出(书目为首行节选):

{"content":[{"id":"89c2642d-fc00-4244-ba1d-67c7273daa37","creationDate":"2026-10-28T10:52:34.295Z","title":"三体","isbn10":null,"isbn13":null,"summary":"","publisher":null,"pageCount":null,"publishe
[{"dropped":0,"finished":1,"year":2026,"pageCount":0,"price":0.0}]
=> HTTP 200

⚠️ 实测怪癖:同一令牌访问 /api/v1/users/current 会返回 400 Bad Request({"timestamp":"2026-10-28T11:37:58.251+0000","status":400,"error":"Bad Request","path":"/api/v1/users/current"}),该端点请改用 Basic 认证或用数据端点验证令牌。

无凭据与错误密码都会被 401 拒绝(实测回显):

{"timestamp":"2026-10-28T11:34:31.532+0000","status":401,"error":"Unauthorized","path":"/api/v1/users/current"}
{"timestamp":"2026-10-28T11:34:31.591+0000","status":401,"error":"Unauthorized","path":"/api/v1/users/current"}

(2)个人 API 令牌(Bearer,最小授权)

个人 API 令牌是给脚本/自动化用的长效凭据,支持 scope 粒度授权。先看平台支持的全部 scope:

curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/api-tokens/scopes

实测输出:

[{"name":"books:read","description":"View books, authors, tags, series, publishers","category":"Books & Metadata"},{"name":"books:write","description":"Create/modify/delete books and metadata","category":"Books & Metadata"},{"name":"reading:read","description":"View reading events and statistics","category":"Reading Events"},{"name":"reading:write","description":"Create/modify/delete reading events","category":"Reading Events"},{"name":"reviews:read","description":"View reviews","category":"Reviews"},{"name":"reviews:write","description":"Create/modify/delete reviews","category":"Reviews"},{"name":"lists:read","description":"View custom lists, shelves, quotes","category":"Lists & Shelves"},{"name":"lists:write","description":"Create/modify/delete lists, shelves, quotes","category":"Lists & Shelves"},{"name":"import:write","description":"Import/export data, browse filesystem","category":"Import/Export"},{"name":"metadata:read","description":"Fetch external metadata from providers","category":"External Metadata"}]

创建令牌时 scopes 是必填字段,缺了会报 400(实测错误信息,用于排障对照):

{"message":"JSON parse error: Cannot construct instance of `io.github.bayang.jelu.dto.CreateApiTokenDto`, problem: Parameter specified as non-null is null: method io.github.bayang.jelu.dto.CreateApiTokenDto.<init>, parameter scopes","violations":[]}

带 name 与 scopes 正常创建:

curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:11111/api/v1/api-tokens \
  -d '{"name":"cli-doc","scopes":["books:read"]}'

实测输出:

{"token":{"id":"9d2fb9d1-0d5f-4e8d-8953-c9b1e08434ca","userId":"8ab5893d-6f33-3ebd-0a80-539927125ff7","name":"cli-doc","scopes":["books:read"],"createdAt":"2026-10-28T11:40:32.079862737Z","lastUsedAt":null,"usageCount":0,"expiresAt":null,"active":true},"rawToken":"jelu_4f544e32b16a81189d90d483f541f3f1"}
=> HTTP 201

⚠️ rawToken 只在创建这一次响应里出现(jelu_ 前缀),请立即保存;之后列表接口只能看到令牌元数据,看不到原文。用 Authorization: Bearer <rawToken> 调用,scope 之外的端点会被 403 拒绝:

RAW=jelu_4f544e32b16a81189d90d483f541f3f1
curl -s -o /dev/null -w "books => HTTP %{http_code}\n" -H "Authorization: Bearer $RAW" http://127.0.0.1:11111/api/v1/books
curl -s -w "\nreviews => HTTP %{http_code}\n" -H "Authorization: Bearer $RAW" http://127.0.0.1:11111/api/v1/reviews

实测输出:

books => HTTP 200
{"timestamp":"2026-10-28T11:40:32.132+0000","status":403,"error":"Forbidden","path":"/api/v1/reviews"}
reviews => HTTP 403

令牌只授了 books:read,访问书评接口即被拒——scope 管控真实生效。查看令牌列表(含用量统计):

curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/api-tokens

实测输出:

[{"id":"632a1dc6-d289-4579-bf6e-7a7bc9dba6f9","userId":"8ab5893d-6f33-3ebd-0a80-539927125ff7","name":"ci-script","scopes":["books:read"],"createdAt":"2026-10-28T11:15:44.424Z","lastUsedAt":"2026-10-28T11:16:19.924Z","usageCount":1,"expiresAt":null,"active":true}]

(3)书目与阅读事件

查书目与阅读状态(userbooks 是「某用户 × 某本书」的阅读维度视图):

curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/books | python3 -m json.tool | head -30

实测输出(节选):

{
    "content": [
        {
            "id": "89c2642d-fc00-4244-ba1d-67c7273daa37",
            "title": "三体",
            "isbn10": null,
            "isbn13": null,
            "authors": [
                {
                    "name": "刘慈欣"
                }
            ],
            "userBookId": "bd40c32c-b368-4475-a441-e96d3859bbb0"
        }
    ]
}
curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/userbooks | python3 -m json.tool | grep -E '"(lastReadingEvent|percentRead|avgRating|userAvgRating)"'

实测输出(节选):

"lastReadingEvent": "FINISHED",
"percentRead": 100,
"avgRating": 5.0,
"userAvgRating": 5.0,

Lucene 全文搜索(按题名/作者模糊匹配):

curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" "http://127.0.0.1:11111/api/v1/books?q=%E4%B8%89%E4%BD%93" | head -c 120; echo

实测输出:

{"content":[{"id":"89c2642d-fc00-4244-ba1d-67c7273daa37","creationDate":"2026-10-28T10:52:34.295Z","title":"三体","isbn10":null,
=> HTTP 200

记录阅读事件。⚠️ 先看错误示范:不带 bookId 会直接 500(实测错误信息):

{"message":"Missing bookId to create reading event","violations":[]}

带 bookId 后创建「读完」事件(已存在的 userBook 会被复用,不会重复建档):

curl -su admin:q1w2e3r4 -o /tmp/re.json -w "=> HTTP %{http_code}\n" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:11111/api/v1/reading-events \
  -d '{"bookId":"89c2642d-fc00-4244-ba1d-67c7273daa37","eventType":"FINISHED","startDate":"2026-10-28T12:00:00.000Z","endDate":"2026-10-28T12:00:00.000Z"}'
cat /tmp/re.json | python3 -m json.tool | grep -E '"(id|eventType|percentRead)"' | head -6

实测输出:

=> HTTP 200
    "id": "7842bb47-8030-40ef-b199-60efd81e771e",
        "id": "bd40c32c-b368-4475-a441-e96d3859bbb0",
        "eventType": "FINISHED",
        "percentRead": 100,

eventType 支持 FINISHED/CURRENTLY_READING/DROPPED 等阅读状态;事件创建后统计与 CSV 导出随即反映。

(4)阅读统计

curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/stats
curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/stats/2026
curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/stats/years

实测输出:

[{"dropped":0,"finished":1,"year":2026,"pageCount":0,"price":0.0}]
=> HTTP 200
[{"dropped":0,"finished":1,"year":2026,"month":10,"pageCount":0,"price":0.0}]
=> HTTP 200
[2026]
=> HTTP 200

解读:/stats 按年聚合(2026 年读完 1 本),/stats/{年份} 细到月份(10 月读完 1 本),/stats/years 返回有统计数据的年份列表。

(5)书评

curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/reviews

实测输出(节选):

{"content":[{"id":"5a78fe5d-bf77-450d-b088-649c8dcab3fa","reviewDate":"2026-10-28T11:15:44.587Z","text":"硬科幻里程碑","rating":5.0,"visibility":"PUBLIC","user":"8ab5893d-6f33-3ebd-0a80-539927125ff7","book":"89c2642d-fc00-4244-ba1d-67c7273daa37"}],"totalElements":1}

(6)导出 CSV 与导入

导出是 POST 触发(⚠️ GET /api/v1/exports 会返回 405 Method Not Allowed),CSV 落在 files/imports/ 下:

curl -su admin:q1w2e3r4 -o /dev/null -w "POST exports => HTTP %{http_code}\n" -X POST http://127.0.0.1:11111/api/v1/exports
ls -la /var/lib/jelu/files/imports/

实测输出:

POST exports => HTTP 201
-rw-r--r-- 1 jelu jelu   14 Oct 28 11:38 isbn-d1.txt.imported
-rw-r--r-- 1 jelu jelu  195 Oct 28 11:15 jelu-export-admin-20261028T111544.csv
-rw-r--r-- 1 jelu jelu  195 Oct 28 11:34 jelu-export-admin-20261028T113445.csv
head -2 /var/lib/jelu/files/imports/jelu-export-admin-20261028T111544.csv

实测输出:

Title,Author,ISBN,Publisher,Date Read,Shelves,Bookshelves,read_dates,tags,authors,isbn10,isbn13,owned,dropped_dates,currently_reading
三体,刘慈欣,,,2026/10/28,,,2026/10/28,,刘慈欣,,,,,

导入接口是 multipart 表单(file + importConfig 两个表单字段;⚠️ 直接发 JSON 会返回 415 Unsupported Media Type)。importSource 只接受四个枚举值,写错会得到列出全部合法值的 400(实测错误信息,排障时直接照抄修正):

{"message":"JSON parse error: Cannot deserialize value of type `io.github.bayang.jelu.dao.ImportSource` from String \"ISBN\": not one of the values accepted for Enum class: [GOODREADS, STORYGRAPH, LIBRARYTHING, ISBN_LIST]","violations":[]}

用 ISBN_LIST(每行一个 ISBN 的文本文件)导入:

printf "9780134685991\n" > /tmp/isbn-d1.txt
curl -su admin:q1w2e3r4 -o /dev/null -w "POST imports => HTTP %{http_code}\n" -X POST \
  -F "file=@/tmp/isbn-d1.txt" \
  -F 'importConfig={"importSource":"ISBN_LIST"};type=application/json' \
  http://127.0.0.1:11111/api/v1/imports
ls /var/lib/jelu/files/imports/
curl -su admin:q1w2e3r4 "http://127.0.0.1:11111/api/v1/books?size=50" | python3 -c "import json,sys;d=json.load(sys.stdin);print('totalElements:',d['totalElements'],'titles:',[b['title'] for b in d['content']])"

实测输出:

POST imports => HTTP 201
isbn-d1.txt.imported
isbns.txt.imported
jelu-export-admin-20261028T111544.csv
jelu-export-admin-20261028T113445.csv
totalElements: 1 titles: ['三体']

现象解读(实测结论):导入请求受理返回 201,但书目不会立刻出现。Jelu 会把上传文件改名为 *.imported(表示已消费、不会重复导入),然后异步逐个 ISBN 调元数据提供方检索;本实测环境对外网元数据源不可达(4.3(8)节的 errors 日志可见),检索无结果时该书目不会创建。读者环境若能正常联网访问 Google/Amazon,导入后会自动建出带完整元数据的书目。

(7)账号管理(管理员)

管理员用 Basic 认证建户。⚠️ CreateUserDto 的 provider 是必填字段,缺了报 400(实测):

{"message":"JSON parse error: Cannot construct instance of `io.github.bayang.jelu.dto.CreateUserDto`, problem: Parameter specified as non-null is null: method io.github.bayang.jelu.dto.CreateUserDto.<init>, parameter provider","violations":[]}

带 provider 建户成功,响应里密码以 **** 掩码:

curl -su admin:q1w2e3r4 -w "\n=> HTTP %{http_code}\n" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:11111/api/v1/users \
  -d '{"login":"temp1","password":"q1w2e3r4","isAdmin":false,"provider":"JELU_DB"}'

实测输出:

{"id":"5fa6e1b2-61e1-49c9-8b04-f86226b5f0dc","creationDate":"2026-10-28T11:38:18.124029805Z","login":"temp1","password":"****","modificationDate":"2026-10-28T11:38:18.124029805Z","admin":false,"provider":"JELU_DB"}
=> HTTP 200

改密用 PUT /api/v1/users/{id}(完整传用户字段 + 新 password):

curl -su admin:q1w2e3r4 -o /tmp/upd.json -w "PUT user => HTTP %{http_code}\n" -H "Content-Type: application/json" \
  -X PUT http://127.0.0.1:11111/api/v1/users/5fa6e1b2-61e1-49c9-8b04-f86226b5f0dc \
  -d '{"login":"temp1","password":"newpass123","email":"","admin":false,"provider":"JELU_DB"}'
curl -su temp1:newpass123 -o /dev/null -w "login temp1 => HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/books

实测输出:

PUT user => HTTP 200
login temp1 => HTTP 200

新口令立即生效。删除用户:

curl -su admin:q1w2e3r4 -o /dev/null -w "DELETE user => HTTP %{http_code}\n" \
  -X DELETE http://127.0.0.1:11111/api/v1/users/5fa6e1b2-61e1-49c9-8b04-f86226b5f0dc

实测输出:

DELETE user => HTTP 204

⚠️ 标签接口只支持查询与关联:GET /api/v1/tags 正常返回分页结构(本环境 totalElements:0),但 POST /api/v1/tags 返回 405 Method Not Allowed——标签的创建只能通过 Web 界面完成。

(8)元数据在线抓取

让 Jelu 按 ISBN 从元数据提供方抓取:

curl -su admin:q1w2e3r4 -m 90 -w "\n=> HTTP %{http_code} time %{time_total}s\n" \
  "http://127.0.0.1:11111/api/v1/metadata?isbn=9780134685991"

实测输出(节选,errors 内的插件日志保留了首尾):

{"amazonId":null,"authors":[],"errors":[{"sourcePlugin":"calibre","errorType":"EXIT_CODE_NOT_ZERO","pluginErrorMessage":"Running identify query with parameters:\n{'title': None, 'authors': [], 'identifiers': {'isbn': '9780134685991'}, 'timeout': 30}\nUsing plugins: Google (1, 1, 1), Amazon.com (1, 3, 7)\n****************************** Google (1, 1, 1) ******************************\nFound 0 results\nDownloading from Google took 30.071556568145752\nTimeoutError: timed out\n****************************** Amazon.com (1, 3, 7) ******************************\nFound 0 results\nOSError: [Errno 101] Network is unreachable\nNo results found"}],"filled":false,"isbn13":null,"title":null}
=> HTTP 200 time 30.434841s

解读:端点本身返回 200,抓取结果在响应体里;errors 数组聚合每个提供方的失败详情。本实测环境访问 books.google.com 超时、google.com 不可达(Network is unreachable),calibre 子进程 30 秒超时后以非零码退出(EXIT_CODE_NOT_ZERO),filled:false 表示未取到元数据。读者环境联网正常时,同一端点会返回 filled:true 且 title/authors/publisher 等字段被填充。

4.4 sqlite3 直查与备份

Jelu 的数据都在单个 SQLite 文件 /var/lib/jelu/jelu.db 里,用 sqlite3 可以直接检查。查看全部表(实测 25 张,含 Liquibase 与会话表):

sqlite3 /var/lib/jelu/jelu.db ".tables"

实测输出:

DATABASECHANGELOG          custom_list              
DATABASECHANGELOGLOCK      import_entity            
SPRING_SESSION             lifecycle                
SPRING_SESSION_ATTRIBUTES  reading_event            
api_token                  review                   
author                     series                   
book                       series_rating            
book_authors               shelf                    
book_narrators             tag                      
book_quote                 user                     
book_series                user_book                
book_tags                  user_message             
book_translators         

核心业务表:book(书目)、author(作者)、user_book(用户×书阅读状态)、reading_event(阅读事件)、review(书评)、tag(标签)、api_token(个人 API 令牌)、user(用户)。查看用户表结构与数据:

sqlite3 /var/lib/jelu/jelu.db ".schema user"

实测输出:

CREATE TABLE user (id BLOB(16) NOT NULL, creation_date TEXT NOT NULL, modification_date TEXT NOT NULL, login VARCHAR(50) NOT NULL, password VARCHAR(1000) NOT NULL, is_admin BOOLEAN NOT NULL, provider varchar(200), CONSTRAINT PK_USER PRIMARY KEY (id));
sqlite3 /var/lib/jelu/jelu.db "SELECT login,is_admin,provider FROM user;"

实测输出:

admin|1|JELU_DB
reader|0|JELU_DB

password 列存 bcrypt 哈希(SELECT 时排除该列即可);provider=JELU_DB 表示本地数据库认证账号。确认当前日志模式(C1 篇实测过 wal 切换,本环境当前为默认 delete):

sqlite3 /var/lib/jelu/jelu.db "PRAGMA journal_mode;"

实测输出:

delete

4.5 fetch-ebook-metadata(calibre 元数据抓取)

fetch-ebook-metadata 是 calibre 自带的在线元数据抓取 CLI,也是 Jelu 默认的元数据提供方(jelu.metadata.calibre.path 指向它)。版本与帮助:

fetch-ebook-metadata --version

实测输出:

fetch-ebook-metadata (calibre 7.6)
fetch-ebook-metadata --help

实测输出(全文):

Usage: fetch-ebook-metadata [options]

Fetch book metadata from online sources. You must specify at least one
of title, authors or ISBN.


Whenever you pass arguments to fetch-ebook-metadata that have spaces in them, enclose the arguments in quotation marks. For example: "/some path/with spaces"

Options:
  --version             show program's version number and exit

  -h, --help            show this help message and exit

  -t TITLE, --title=TITLE
                        Book title

  -a AUTHORS, --authors=AUTHORS
                        Book author(s)

  -i ISBN, --isbn=ISBN  Book ISBN

  -I IDENTIFIER, --identifier=IDENTIFIER
                        Identifiers such as ASIN/Goodreads id etc. Can be
                        specified multiple times for multiple identifiers. For
                        example: --identifier asin:B0082BAJA0

  -v, --verbose         Print the log to the console (stderr)

  -o, --opf             Output the metadata in OPF format instead of human
                        readable text.

  -c COVER, --cover=COVER
                        Specify a filename. The cover, if available, will be
                        saved to it. Without this option, no cover will be
                        downloaded.

  -d TIMEOUT, --timeout=TIMEOUT
                        Timeout in seconds. Default is 30

  -p ALLOWED_PLUGIN, --allowed-plugin=ALLOWED_PLUGIN
                        Specify the name of a metadata download plugin to use.
                        By default, all metadata plugins will be used. Can be
                        specified multiple times for multiple plugins. All
                        plugin names: Google, Google Images, Amazon.com,
                        Edelweiss, Open Library, Big Book Search

参数要点:-d 默认 30 秒超时(与 4.3(8)节 Jelu 实测的 30.4 秒总耗时吻合);内置插件为 Google、Google Images、Amazon.com、Edelweiss、Open Library、Big Book Search;-o 输出 OPF 格式(可喂给其他工具)。

按 ISBN 检索(本实测环境外网元数据源不可达,拿到真实的「无结果」输出——正好演示失败形态与退出码):

fetch-ebook-metadata --isbn 9787532776413; echo "exit=$?"

实测输出:

Running identify query with parameters:
{'title': None, 'authors': [], 'identifiers': {'isbn': '9787532776413'}, 'timeout': 30}
Using plugins: Google (1, 1, 1), Amazon.com (1, 3, 7)
The log from individual plugins is below

****************************** Google (1, 1, 1) ******************************
Found 0 results
Downloading from Google took 0.03614044189453125
Insufficient metadata to construct query

********************************************************************************

****************************** Amazon.com (1, 3, 7) ******************************
Found 0 results
Downloading from Amazon.com took 0.026116132736206055
User-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Server: auto
Insufficient metadata to construct query, none of title, ISBN or ASIN supplied

********************************************************************************
The identify phase took 0.20 seconds
The longest time (0.036140) was taken by: Google
Merging results from different sources
We have 0 merged results, merging took: 0.00 seconds

No results found
exit=1

解读:检索无结果时输出 No results found 且退出码为 1——脚本里可以用 $? 判断成败;联网正常的环境同样的命令会打印书名、作者、出版信息。Jelu 调用它时通过 identifiers 传入 ISBN(4.3(8)节 errors 日志里的 Running identify query 即它的工作日志)。

4.6 备份与恢复

Jelu 全部业务数据在 jelu.db 单文件里(封面/导入文件在 files/ 下),备份两件套:sqlite3 在线备份 + 文件复制。

在线备份(不用停服务;用 runuser -u jelu 执行保证备份文件属主为服务用户):

mkdir -p /var/lib/jelu/backup && chown jelu:jelu /var/lib/jelu/backup
runuser -u jelu -- sqlite3 /var/lib/jelu/jelu.db ".backup /var/lib/jelu/backup/jelu-20261028.db"

.backup 是 SQLite 在线备份 API,能拿到一致性快照(比直接 cp 正在写的库文件安全)。校验备份完整性:

sqlite3 /var/lib/jelu/backup/jelu-20261028.db "PRAGMA integrity_check;"
sqlite3 /var/lib/jelu/backup/jelu-20261028.db "SELECT count(*) FROM book;"
ls -la /var/lib/jelu/backup/

实测输出:

ok
1
-rw-r--r-- 1 jelu jelu 589824 Oct 28 11:44 jelu-20261028.db

integrity_check 返回 ok、书目行数与线上一致(1),备份可用。

恢复演练(把备份回灌为主库;当前备份与主库内容一致,可安全演练):

systemctl stop jelu
cp /var/lib/jelu/backup/jelu-20261028.db /var/lib/jelu/jelu.db && chown jelu:jelu /var/lib/jelu/jelu.db
systemctl start jelu
sleep 8
systemctl is-active jelu
curl -s -o /dev/null -w "curl => HTTP %{http_code}\n" http://127.0.0.1:11111/api/v1/setup/status
curl -su admin:q1w2e3r4 http://127.0.0.1:11111/api/v1/books | python3 -c "import json,sys;d=json.load(sys.stdin);print('books totalElements:',d['totalElements'])"

实测输出:

active
curl => HTTP 200
books totalElements: 1

恢复后服务正常、数据完整。要点提示:

  • 若 C1 篇把 journalMode 切到了 wal,库目录会有 jelu.db-wal/jelu.db-shm 两个伴生文件,文件级备份必须三者一起拷,或先停服再拷;
  • CSV 导出(4.3(6)节)是跨系统迁移用的「逻辑备份」,与 jelu.db 物理备份互为补充;
  • 长期方案建议 cron 定时执行 .backup 并异地保存 backup/ 目录。

五、注意事项

以下要点来自实测中踩过或验证过的行为,操作前过一遍可避免大部分坑:

  • 工作目录敏感:java -jar 前台启动必须 cd /var/lib/jelu,否则 write.lock 创建失败导致启动失败(4.2 节实录)。
  • 端口与锁独占:systemd 服务与前台实例二选一;同时跑两个实例会撞 11111 端口并争抢 SQLite/Lucene 锁。
  • 重启就绪窗口:systemctl restart 后立即访问会拿到 HTTP 000,实测约 8 秒后就绪,脚本里重试再判定。
  • API 令牌最小授权:rawToken 仅创建时可见;按需授予 scope(实测越权 403);令牌泄露后应在界面或 API 立即吊销。
  • 导入是异步的:POST /api/v1/imports 201 只代表受理;书目出现依赖元数据提供方检索成功,源文件会被改名为 *.imported 不会重复导入。
  • 元数据源可达性:calibre 默认走 Google/Amazon 等,本实测环境不可达时 30 秒超时返回 filled:false;自建环境若在内网,需给服务器配代理或换用可达的元数据插件。
  • 直查数据库只读为主:sqlite3 直查适合核对与备份,业务写入请走 API/界面,避免绕过应用层校验。

六、常见问题

以下问题全部来自本文实测过程,每条给出真实错误信息与已验证的解决办法:

Q1:java -jar 直接启动报 NoSuchFileException: /write.lock 后退出? 工作目录不对。Jelu 启动时在当前目录创建 write.lock,从 / 等无权目录启动即失败(4.2 节完整实录)。解决:cd /var/lib/jelu 后再启动,与 systemd 的 WorkingDirectory 保持一致。

Q2:重启服务后立刻 curl 返回 HTTP 000? 服务尚未完成启动。实测重启后立即访问 000、约 8 秒后 200(4.1 节实录)。健康检查脚本加重试等待即可。

Q3:POST /api/v1/tags 或 GET /api/v1/exports 返回 405? 设计如此:标签创建只支持 Web 界面(API 仅查询/关联);导出必须用 POST 触发(4.3(6)节)。按正确方法调用即可。

Q4:POST /api/v1/reading-events 返回 500 Missing bookId to create reading event? 请求体缺少 bookId 字段。带上书目 id(如 {"bookId":"89c2642d-fc00-4244-ba1d-67c7273daa37","eventType":"FINISHED"})即可,实测 200(4.3(3)节)。

Q5:建户 400、创建令牌 400、导入 400? 三个 DTO 各有必填字段:建户必须带 "provider":"JELU_DB";创建 API 令牌必须带 scopes 数组;importSource 只接受 GOODREADS/STORYGRAPH/LIBRARYTHING/ISBN_LIST(实测错误信息见 4.3(2)(6)(7)节,报错信息会列出合法值)。

Q6:用 X-Auth-Token 访问 /api/v1/users/current 返回 400? 实测该端点对令牌认证返回 400(对 Basic 正常)。验证令牌请改用数据端点(如 /api/v1/books、/api/v1/stats,4.3(1)节)。

Q7:ISBN 导入返回 201 但书目没出现? 正常现象:导入异步执行,先把文件改名 *.imported,再逐个 ISBN 向元数据提供方检索;检索无结果(如本环境外网不可达)则不建书目(4.3(6)节实测)。可在 /api/v1/metadata?isbn=9780134685991 的 errors 里看具体失败原因。

Q8:Bearer 令牌访问部分接口返回 403? 该令牌未授予对应 scope。GET /api/v1/api-tokens/scopes 查全部 scope,重新创建令牌时按需授予(4.3(2)节实测:仅授 books:read 的令牌访问书评 403)。

七、参考链接

本文所属系列与官方资源汇总(站内链接可点击跳转):