LedgerSMB 1.13.8 API 使用详解

LedgerSMB 1.13.8 内置 HTTP API 实战:company_path 挂载规则、login.pl 认证与会话 Cookie、session 会话端点、集合资源与 8 种导出格式、POST/PUT/ETag 乐观锁、PATCH/DELETE 与 501 能力边界,请求响应全部 2026-10-24 实测回显。

LedgerSMBLedgerSMBERP财务记账2026/10/24

LedgerSMB 1.13.8 API 使用详解

目标:面向需要把 LedgerSMB 接入自研系统、做批量数据维护或构建集成脚本的读者,讲清 1.13.8 内置 HTTP API 的挂载方式、认证会话机制、可用的资源端点、乐观锁更新流程,以及实测探明的功能边界(哪些能力可用、哪些尚未开放)。本文所有请求与响应均于 2026-10-24 在本专栏部署篇所建的 1.13.8 实例(演示账套 demo1,示例账号 admin)上实测取得,回显逐行照抄,未做任何推测性补写。

一、API 形态总览

LedgerSMB 1.13 系列在传统 login.pl / erp.pl Web 表单路由之外,内置了一层 REST 风格的 JSON API。它不是独立服务,而是同一应用内的一组路由:以 PSGI 方式托管时,API 与 Web UI 共用同一个 HTTP 端口(本实验环境为 8130),由应用内的路由器分发(发行包内 lib/LedgerSMB/Router.pm)。

挂载前缀公式(1.13.8 实测):

http://<host>:<port>/<company_path>/erp/api/v0/

其中 company_path 是账套名的 MD5 十六进制值。实测验证:账套名 demo1 的 MD5 为 e368b9938746fa090d6afd3628355133(echo -n demo1 | md5sum 结果),以此拼出的 API 前缀全部命中。这一设计在发行包内 lib/LedgerSMB/PSGI.pm 中实现(公司路径取公司名的 MD5),目的是让 URL 不直接暴露真实数据库名。

能力边界速览(全部为 2026-10-24 实测结论,细节见后文):

能力实测结论
认证建立会话✅ POST /login.pl?action=authenticate,JSON 凭据
会话信息查询✅ GET .../session(偏好、角色、密码有效期)
基础数据读列表✅ 商品组/币别国家/语言/科目/模板/菜单节点等
单条读取✅ 带 ETag 响应头
新建资源✅ POST 返回 201 Created + ETag
全量更新✅ PUT + If-Match 乐观锁(强制)
部分更新⚠️ PATCH 实测 500(路由层异常,见第六节)
删除资源⚠️ DELETE 带 If-Match 实测 403 Forbidden
发票/订单域❌ GET /invoices、GET /orders 返回 501 Not Implemented
接口自描述❌ /erp/api/v0/doc 等文档端点实测 404,无 OpenAPI/Swagger 输出

💡 读到 501 或 403 不必怀疑自己拼错了 URL——它们就是 1.13.8 的"真实边界"。LedgerSMB 的 API 覆盖是渐进式的:基础资料与商品类资源已经 REST 化,而发票、订单等核心单据域尚未开放,仍需走 Web 表单或官方 CLI 途径。

二、认证与会话:login.pl 三步走

API 没有独立的 token 发放端点,会话载体是 HttpOnly Cookie。完整认证分两步,外加一个可选的会话确认请求。

第 1 步:获取匿名会话 Cookie。对登录页发一个普通 GET,服务器会种下一个匿名会话 Cookie:

curl -s -c jar.txt -D - http://localhost:8130/login.pl

实测响应头(逐行照抄):

HTTP/1.1 200 OK
Set-Cookie: LedgerSMB=01nzLPYaSo5CSZ3UfZpy_PRSIEvMj6A0EE-_wErDS2w~1800590805~WymMuFl7NFz9Ug5oG476a05AeWBiYhKZJQMiWBuZUkgsJq-EFUebz98SkfyH4b-i~aAJRGMBOfoRMGcD8ODaeIX7Qx1NUC_0xJ9HGW3GU6rE~2; path=/; expires=Thu, 01-Jan-1970 00:00:01 GMT; SameSite=Strict; HttpOnly

第 2 步:提交认证。以 JSON 体内联提交登录名、密码与账套名:

curl -s -b jar.txt -c jar.txt -D - -H 'Content-Type: application/json' \
  -X POST 'http://localhost:8130/login.pl?action=authenticate' \
  -d '{"login":"admin","password":"q1w2e3r4","company":"demo1"}'

实测响应(逐行照抄,Cookie 值因会话而异):

HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: LedgerSMB=nZecFe-8Fh5UUqr6KAF96KHYa6uyPVZtXXEp2aGb0_0~1800590806~hDL0CUao6fSYmLZDuDdr4Y4ENwSeokvwR-oQUgia8puBxyOBr4JbI9bGTmHqUODLUi9ar9RHiM2Rnz8Wi7rkmNNIBL-Wgws4yamOucnU9IRWBpAcc8qt0gNCWGL6tvggKN7cDtPzjaRCPzqUvhOiPX_Zk1IoitHJzyibAlH9ZTKirzeU1J0yo0d3u7yW7F7qU1EWkwWFu3hSv5dEVgYJA8SZSLYoOjoqEGcuqt9anyOs01fqGKlpXKfky4ZnUL0PSv1_GYickUsgG-BYCQZCZJ4KrUj4VW8fOS2h1qm_jB9i91AfLpKghJLp6QC2KViy-7-eeDGpwITGwb41zzZHHhExeJJzrm2VOs26RqYaDvFvuXdLf0bwdZ8Howb2FiAo~B_qUJ-xZ-c-x5IYeySaua_WHVhUP4QjBUixh6QiV2TY~2; path=/e368b9938746fa090d6afd3628355133/; SameSite=Strict; HttpOnly

{ "target":  "e368b9938746fa090d6afd3628355133/erp.pl?user=admin" }

三个关键信息都在这一步给出:

  1. 响应体 target 字段直接返回了该账套的 API 挂载前缀(demo1 的 MD5),客户端无须自己计算 MD5;
  2. 认证成功的会话 Cookie 的 path 被收窄到 /<company_path>/——会话凭证按账套隔离,同浏览器登录多账套互不干扰(这与 1.13 主打的多公司并发登录特性吻合);
  3. Cookie 属性带 HttpOnly 与 SameSite=Strict,浏览器侧脚本无法读取,跨站请求也不会携带。

__action 等价写法:POST /login.pl?__action=authenticate(双下划线)实测返回完全相同的 200 与响应体,与部署篇的登录验收命令一致。两种写法在 1.13.8 中等价。

认证失败矩阵(全部实测,响应体同为单行文案):

场景实测状态码响应体
密码错误401 UnauthorizedCredentials invalid or session expired
账套不存在(company=nosuchdb)401 UnauthorizedCredentials invalid or session expired
不带 Cookie 直接访问 API401 UnauthorizedCredentials invalid or session expired
伪造 company_path(全 0 的 MD5)401 UnauthorizedCredentials invalid or session expired

💡 四种失败场景返回同一段文案,服务端不区分"密码错"与"账套不存在"——这是刻意设计,避免向探测者泄露数据库是否存在。做集成时不要试图从 401 文案里区分失败原因,应引导用户检查账号、密码、账套名三要素。

三、会话信息端点:GET session

带着认证 Cookie 请求 API 根下的 session 端点,可确认会话有效并读取当前用户的偏好与权限:

curl -s -b jar.txt \
  http://localhost:8130/e368b9938746fa090d6afd3628355133/erp/api/v0/session

实测响应(逐行照抄,roles 数组较长,此处截取首尾):

{
   "preferences" : {
      "dateformat" : "yyyy-mm-dd",
      "numberformat" : "1000.00",
      "printer" : null,
      "language" : null,
      "stylesheet" : null
   },
   "roles" : [
      "account_all",
      "account_create",
      "account_delete",
      "account_edit",
      "account_link_description_create",
      "ap_all",
      ...(中间省略 120 余项)...
      "users_manage",
      "voucher_delete",
      "warehouse_create",
      "warehouse_edit",
      "yearend_reopen",
      "yearend_run"
   ],
   "password_expiration" : "PT15H32M17.370174S"
}

三个字段的含义:

  • preferences:用户偏好(日期格式 yyyy-mm-dd、数字格式 1000.00),与登录后 Web 界面使用的一致;做本地化渲染时可直接取用。
  • roles:当前用户在本账套内的权限角色全量清单(实测 admin 账号共 136 条,覆盖科目、应收应付 ap_*/ar_*、库存 inventory_*、订单 orders_*、系统管理 system_admin 等)。API 的权限模型与 Web UI 完全同源——同一用户调 API 能做什么,与其在界面上的角色一致。
  • password_expiration:ISO 8601 时长格式(PT15H32M17.370174S 即约 15 小时 32 分),当前密码距过期的剩余时间,可用于客户端提前提醒改密。

四、集合资源与导出链接

API 的集合端点返回统一的 JSON 骨架:_links(导出链接)+ items(数据项)。以商品组为例:

curl -s -b jar.txt \
  http://localhost:8130/e368b9938746fa090d6afd3628355133/erp/api/v0/products/partsgroups

实测响应骨架(逐行照抄,items 截取):

{
   "_links" : [
      {
         "rel" : "download",
         "title" : "PDF",
         "href" : "?format=PDF"
      },
      {
         "rel" : "download",
         "title" : "PS",
         "href" : "?format=PS"
      },
      {
         "href" : "?format=XLS",
         "title" : "XLS",
         "rel" : "download"
      },
      ...(XLSX、ODS、CSV、TXT、HTML 同构)...
   ],
   "items" : [
      {
         "id": 1,
         "_meta": { "ETag": "e0721b361b77f26fa04937b033bd3d96" },
         "parent": null,
         "description": "API 实测商品组-已更新"
      },
      ...
   ]
}

两个值得记住的协议细节:

  1. _links 导出矩阵:每个列表端点都自带 8 种导出格式链接(实测 titles:PDF、PS、XLS、XLSX、ODS、CSV、TXT、HTML),在列表 URL 后拼 ?format=CSV 即可直接拿到对应格式文件。给非技术同事导数据时,这一招比写脚本更快。
  2. items[].​_meta.ETag:列表内每条记录都内嵌了自己的 ETag。做批量更新时不必逐条 GET 单取 ETag——列表一次拿全,直接作为 If-Match 使用。

实测可用的集合端点(2026-10-24 全部验证为 200):

端点实测规模/结构说明
/products/partsgroups3 项(实验账套)商品组,{id, description, parent}
/products/pricegroups200价格组
/products/warehouses200仓库
/products/goods/200(空数组 [])商品/服务目录(注意尾斜杠,见下)
/countries263 项国家/币别基础资料,含 code、localizedName
/languages43 项语言资料,如 {code: "ar_EG", description: "Arabic (Egypt)"}
/menu-nodes顶层数组菜单树,{id, label, parent, menu, url} 结构
/accounts/科目数组科目表,含 accno、label、category(如 "1061--支票户头",category: "A")
/templates/55 条单据模板清单,每条含 template_name、format、src(内容下载路径 /erp/api/v0/templates/<id>/content)

尾斜杠语义:集合路由是否带尾斜杠不可随意省略。实测同一前缀下 GET .../erp/api/v0/goods(无斜杠)返回 404,而 GET .../erp/api/v0/goods/(有斜杠)返回 200 与空数组。写集成代码时把 URL 常量与官方路由完全对齐,避免这类"看不见的 404"。

五、资源创建与乐观锁更新:POST / PUT / ETag

写操作是这套 API 设计最完整的部分,核心机制是 ETag 乐观锁:每次成功修改后资源的 ETag 都会变化,更新必须携带最新 ETag 作为 If-Match 前置条件,否则被拒。以下序列全部为 2026-10-24 在 demo1 账套上的实测回显(逐行照抄)。

创建:POST 提交 JSON,返回 201 Created、ETag 响应头与新建资源体:

curl -s -b jar.txt -D - -H 'Content-Type: application/json' \
  -X POST .../erp/api/v0/products/partsgroups \
  -d '{"description":"D5-API-evidence-group"}'

实测响应:

HTTP/1.1 201 Created
ETag: "f617be7f04c8aef0b5ea484b6754b9c4"

{
   "id" : 4,
   "description" : "D5-API-evidence-group",
   "parent" : null
}

单条读取:GET .../products/partsgroups/4 返回 200,响应头携带同一 ETag(ETag: "f617be7f04c8aef0b5ea484b6754b9c4"),响应体为 {description, parent, id} 三键。

更新三态(1.13.8 实测矩阵,这是本文最重要的实测结论之一):

请求形态实测结果语义
PUT 不带 If-Match500 Internal Server Error,响应体 Internal Server Error服务端异常而非规范化的 428——1.13.8 对缺失前置条件没有给出友好错误
PUT 带过期/错误 If-Match(如 "deadbeefdeadbeefdeadbeefdeadbeef")409 Conflict(空响应体)并发冲突拒绝:你手里的版本已经不是最新
PUT 带当前 If-Match200 OK + 新 ETag更新成功

带当前 ETag 的成功更新实测:

curl -s -b jar.txt -D - -H 'Content-Type: application/json' \
  -H 'If-Match: "f617be7f04c8aef0b5ea484b6754b9c4"' \
  -X PUT .../erp/api/v0/products/partsgroups/4 \
  -d '{"id":4,"description":"put-with-current-etag-ok"}'

实测响应:

HTTP/1.1 200 OK
ETag: "6a13e70925744985f7451f46c3a97eb5"

{
   "parent" : null,
   "description" : "put-with-current-etag-ok",
   "id" : 4
}

注意两点:ETag 已变为新值 6a13e70925744985f7451f46c3a97eb5,下一次更新必须用它;PUT 请求体必须包含 id 字段且与路径一致。

请求体校验:PUT 缺少 id 字段时实测返回 400 Bad Request,错误结构逐行照抄:

{
   "error" : 1,
   "errors" : [
      {
         "context" : [
            {
               "instance_path" : "/",
               "schema_path" : "/allOf/1/required/0",
               "message" : "instance does not have required property \"id\""
            }
         ],
         "message" : "request body error"
      }
   ],
   "msg" : "LedgerSMB API errors"
}

这是标准的 JSON Schema 校验回显(schema_path 直接指向校验规则位置)。集成端可以直接解析 errors[].context[].message 给出人话提示。

💡 乐观锁的意义在于多人记账场景:财务与库管可能同时改同一条基础资料。HTTP 标准中 If-Match 失败通常是 412 Precondition Failed,而 LedgerSMB 1.13.8 实测返回 409 Conflict(空体)——集成代码应同时兼容 409/412 两个状态码,并遵循"重新 GET 拿新 ETag 再重试"的标准恢复流程。

六、PATCH 与 DELETE 的现实边界

这一节是给计划做深度集成者最实用的"避坑"实测。1.13.8 的 PATCH 与 DELETE 在商品类资源上路由存在,但执行层未达可用状态。

PATCH:无论 JSON 体形态还是查询参数形态,实测均返回 500:

PATCH .../products/partsgroups/4  (If-Match + {"description":"patch-json-body"})
→ HTTP/1.1 500 Internal Server Error / Internal Server Error

PATCH .../products/partsgroups/4?description=patch-query-param  (无请求体)
→ HTTP/1.1 500 Internal Server Error / Internal Server Error

服务端日志捕获到根因(逐行照抄):

Can't call method "message" on an undefined value at /srv/ledgersmb/lib/LedgerSMB/Router.pm line 501, <$io> line 1.
2026/10/24 04:08:48 Unexpected exit; rolling back current db transaction

即路由框架层在处理 PATCH 请求时崩溃(Router.pm 第 501 行对未定义值调用了 message 方法),事务被回滚——这不是业务规则拒绝,而是 1.13.8 的代码缺陷。结论:部分更新不要走 PATCH,改用 PUT 全量提交。

DELETE:不带 If-Match 实测 500;带上 If-Match 实测 403 Forbidden(空响应体),服务端日志显示事务正常提交后返回 403(Server response: 403 + Committing current db transaction)——即删除请求被设计性拒绝,资源实际不会被删除(本文实测创建的商品组 id=4 也因此仍留存于演示账套中)。

给集成者的替代路径:确需删除/停用基础资料时,走 Web UI 的对应管理页面(部分资源支持"废弃/obsolete"标记而非物理删除),或在数据库层按官方备份流程操作后谨慎处理。把 DELETE 403 视为"1.13.8 尚未开放删除能力"的信号,不要在重试逻辑里死循环。

七、尚未开放的资源域:501

核心单据域尚未 REST 化。实测:

GET .../erp/api/v0/invoices  → HTTP/1.1 501 Not Implemented(空响应体)
GET .../erp/api/v0/orders    → HTTP/1.1 501 Not Implemented(空响应体)

501 的语义是"服务器已理解请求路径,但该能力未实现"——与 404(路由不存在)和 403(拒绝执行)都有区别。这也解释了 LedgerSMB API 的当前定位:适合基础资料同步与查询类集成,开票/订单等业务单据仍应以 Web 界面或账套内直接操作为主。选型评估(见本专栏介绍篇)中把 LedgerSMB 定位为"账务正确性优先",API 的渐进式覆盖与之同频。

八、错误响应速查表

全部状态码均来自 2026-10-24 实测,可直接作为集成测试的断言基线:

状态码触发场景(实测)响应体特征
200 OK会话查询、列表/单条读取、PUT 成功JSON;写操作回新 ETag
201 CreatedPOST 创建成功新资源 JSON + ETag 头
400 Bad RequestPUT 请求体缺必填字段(如 id)JSON Schema 校验结构(error/errors/msg)
401 Unauthorized无凭据/密码错/账套不存在/伪造 company_path单行 Credentials invalid or session expired
403 ForbiddenDELETE 且携带 If-Match空体;删除被设计性拒绝
404 Not Found集合路由缺尾斜杠(/goods)、不存在的文档端点(/doc)空体
409 ConflictPUT 携带过期 If-Match空体;重新 GET 再重试
500 Internal Server ErrorPUT 缺 If-Match、PATCH 任意形态、DELETE 缺 If-Match单行 Internal Server Error;查服务端日志定位
501 Not Implementedinvoices、orders 等未开放域空体

九、端到端脚本示例

把上述实测串成一个可直接复制的集成脚本(bash + curl,逐行对应本文实测命令):

#!/bin/bash
# LedgerSMB 1.13.8 API 集成最小流程(2026-10-24 实测通过)
BASE=http://localhost:8130
COMPANY=demo1
CP=e368b9938746fa090d6afd3628355133          # md5(公司名);也可从认证响应 target 字段解析
API=$BASE/$CP/erp/api/v0
JAR=$(mktemp)

# 1) 种匿名会话 Cookie
curl -s -c $JAR $BASE/login.pl > /dev/null

# 2) 认证(响应体 target 字段即含 company_path)
curl -s -b $JAR -c $JAR -H 'Content-Type: application/json' \
  -X POST "$BASE/login.pl?action=authenticate" \
  -d '{"login":"admin","password":"q1w2e3r4","company":"demo1"}'

# 3) 确认会话与权限
curl -s -b $JAR $API/session | python3 -m json.tool

# 4) 读列表(每条内嵌 _meta.ETag,可直接用于 If-Match)
curl -s -b $JAR $API/products/partsgroups/

# 5) 创建
ETAG=$(curl -s -b $JAR -D - -o /dev/null -H 'Content-Type: application/json' \
  -X POST $API/products/partsgroups \
  -d '{"description":"集成新建组"}' | grep -i '^etag' | tr -d '\r' | sed 's/^[Ee]tag: *//')

# 6) 乐观锁更新(必须带 If-Match;失败得到 409 则回到第 4 步重取)
curl -s -b $JAR -D - -H 'Content-Type: application/json' \
  -H "If-Match: $ETAG" -X PUT $API/products/partsgroups/4 \
  -d '{"id":4,"description":"集成更新后的组"}'

脚本要点:会话全程依赖 Cookie Jar;每次写操作后 ETag 都在变,务必从最近一次写响应头取值;409 是"重取再试"的信号而非致命错误。

十、安全与生产注意

  1. company_path 不是密码。MD5 挂载前缀只是路径混淆(URL 不暴露库名),401 矩阵已证明伪造前缀照样被拒。不要在前端代码里把它当作秘密——认证只认账号、密码与 Cookie。
  2. 会话 Cookie 强度。HttpOnly + SameSite=Strict 已由服务端强制,客户端脚本拿不到会话;但生产环境仍必须上 HTTPS(呼应介绍篇的 Safari 限制与部署篇的 TLS 结论),否则 Cookie 本体在链路上明文可截。
  3. 密码过期感知。session 端点的 password_expiration 是做"改密提醒"的官方数据源;集成系统应消费该字段而不是让用户撞到登录失败才反应。
  4. 500 排障路径。遇到 500 先看服务端日志(本文 PATCH 的根因即从日志行 Router.pm line 501 定位),不要靠盲试参数;1.13.8 的 500 多为"能力未就绪"而非配置问题。
  5. 权限同源。API 不提供超权通道:roles 清单就是权限天花板。为集成建专用账号时,在 Web UI 中按最小权限授予角色,API 侧自动受限。

十一、参考链接