记账软件问「这笔钱花在哪」,家底只问「现在家里有多少钱」。没有一笔笔流水,只有固定周期的盘点。
Node 22、零第三方依赖、MIT。服务端 server.mjs 484 行 + 领域校验 ledger-domain.mjs 61 行 + 浏览器侧 shared-client.js 208 行。这篇写数据怎么进来、怎么被校验、怎么保证不丢,以及几个必须自己扛的坑。

一、环境与代码规模
| 项 | 值 |
|---|---|
| 运行时 | Node.js ≥ 22(用原生 node:fs/promises、node:crypto、ESM) |
| 依赖 | 0 个第三方包,npm install 都不用跑 |
| 形态 | fnOS 微应用(fpk,走网关 socket)/ Docker(compose.yaml)/ node server.mjs |
| 代码 | server.mjs 484 行、ledger-domain.mjs 61 行、shared-client.js 208 行、单文件前端 |
| 存储 | data/ledger.json(唯一真源)+ backup/ + 卷根 .jiadi-keep/ |
| 前端 | 无构建步骤:改一行刷新就生效 |
server.mjs 单进程 HTTP 服务:路由、锁、原子写、会话、加解密、备份ledger-domain.mjs 纯领域函数:normalizeData() 等,可在浏览器与服务端共用shared-client.js 前端共用逻辑(取数、格式化、隐私遮罩)jiadi-ui.html 主界面(单文件)fnos/ manifest / wizard / cmd:安装向导与生命周期脚本website/ 官网(静态单页)docs/ ACCEPTANCE.md + screenshots
二、四条约束决定了形状
| 约束 | 直接后果 |
|---|---|
| 账本是最私密的数据 | 不连银行、不上云、不出内网;导出默认加密 |
| 装在家里的 NAS 上长期跑 | 零依赖(没有供应链和升级问题)、单进程、单文件账本 |
| 家里人一起看,手机电脑都要开 | 只出一个 HTTP 服务,前端同一份响应式页面 |
| 用的人不是会计 | 录入只有「余额」,口径写死在代码里,不给开关 |
三、校验:所有写入先过一道闸
normalizeData() 不是「顺手校验」,它是领域不变量的唯一定义处——服务端、导入、恢复都走这一份:
const money = (v, label, max = 1e12) => {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > max
|| Math.round(v * 100) / 100 !== v) // 恰好两位小数,多一位都拒
fail(label + ' 必须是范围内的有效非负金额(最多两位小数)')
return v
}
const date = (v, label, empty = false) => {
if (empty && v === '') return v
const parsed = /^\d{4}-\d{2}-\d{2}$/.test(v) ? new Date(v + 'T00:00:00Z') : null
if (!parsed || parsed.toISOString().slice(0, 10) !== v) // 往返不一致即非法
fail(label + ' 不是有效日期')
return v
}日期用「往返序列化是否一致」判,比正则狠:2026-02-30 正则能过、new Date 会滚到 3 月 2 日,往返一比对就露馅。
被明确拒绝的还有:账户类别不在 现金 / 存款 / 投资 / 固定资产 / 负债 白名单;类别与 isDebt 不一致;账户归属的成员不存在(且成员名唯一、颜色必须六位十六进制);历史记录日期晚于账户数据日期。历史记录由服务端排序,不由前端排序。
失败返回 400 + 具体原因,不静默纠正——这一层挡住了「导入一份手工 CSV 把账本搞乱」的大多数事故。

四、写盘:单写者 + 原子替换
同一个数据目录只允许一个写者,取锁用 O_EXCL 建 server.lock 写 PID:
async function acquireLock() {
try {
const fh = await open(lockPath, 'wx', 0o600)
await fh.writeFile(String(process.pid)); return fh
} catch (e) {
if (e.code !== 'EEXIST') throw e
const pid = parseInt(String(await readFile(lockPath, 'utf8')).trim(), 10) || 0
let alive = false
if (pid > 0 && pid !== process.pid) {
try { process.kill(pid, 0); alive = true }
catch (err) { alive = err && err.code === 'EPERM' } // EPERM = 存在但没权限,也算活着
}
if (alive) throw new Error('数据目录已被占用(PID ' + pid + ' 仍在运行)')
await unlink(lockPath) // 僵尸锁:持有者已死,直接接管
const fh = await open(lockPath, 'wx', 0o600)
await fh.writeFile(String(process.pid)); return fh
}
}为什么要有「僵尸锁接管」:NAS 断电或 docker stop 超时留下的锁文件,如果不自动判断持有者是否存活,用户就得 SSH 进去手删——这是家庭应用最不该有的运维动作。
写账本本身是 temp + rename + fsync,失败时 ledger.json 保持上一个完好版本,不会出现半个 JSON。
覆盖式操作单独走保险:导入账本、恢复演示、清空这类操作,会先把当前账本复制一份到卷根的 .jiadi-keep/:
const keepDir = join(dataDir, '..', '..', '..', '.jiadi-keep') // dataDir = <卷>/@appdata/<app>/data
await mkdir(keepDir, { recursive: true, mode: 0o700 })
await writeFile(join(keepDir, `ledger-before-${tag}-${stamp}.json`), current)三级上跳到卷根而不是留在 data/:删应用时 @appdata 会被清掉,备份跟着一起没;放卷根才符合「我的东西在这个盘里」的直觉。
五、鉴权:一个共享密码,四道闸
家庭场景没有账号体系,只有共享密码:
| 环节 | 做法 |
|---|---|
| 存储 | scrypt(N=32768, r=8, p=1, maxmem=64MB) 派 32 字节,存 salt(16B hex)+ hash;比对用 timingSafeEqual |
| 首次设置 | 安装向导填的密码由 cmd/install_callback 只写进 data/.pwseed(0600),服务启动时转成 vault 后立即删除 |
| 会话 | 登录态 cookie;无操作 15 分钟自动上锁(JIADI_IDLE_LOCK_MS 可覆盖,便于测试) |
| 微应用网关 | 走 unix socket + 网关前缀,身份取 x-trim-userid 等透传头 |
// 网关头只在显式信任网关时才读,否则一律走正常登录
function readGatewayIdentity(headers, trustGateway) {
if (!trustGateway) return null
const uid = String(h['x-trim-userid'] || '').trim()
if (!uid) return null
return { uid, username: ..., isAdmin: ... }
}「上锁」和「登出」是两个状态:上锁保留会话但要重新输密码(家里共用平板时防顺手翻账本),登出才销毁会话。
六、备份:加密容器 + 双 KDF 兼容
导出不是复制 JSON,是真加密:
format: jiadi.encryptedv: 1kdf: scrypt-N32768-r8-p1 | pbkdf2-sha256-210000cipher: aes-256-gcmsalt: 16B iv: 12B tag: 16B data: <密文>- 读端双 KDF,写端只出新格式:早期版本用 PBKDF2-210000,后来换 scrypt。老备份不会因为升级解不开,但不再产生弱格式;
- 格式字段先校验再解密:
format / v / kdf / cipher任一不符直接 400,不去猜; - 密码长度上限 128,避免超长输入把 KDF 变成 CPU 炸弹。
七、口径写死在代码里
家底刻意不提供统计口径开关——家庭账本一旦口径可配,两个人会看到两个数字,然后开始吵架:
- 负债率 = 总负债 / 总资产,不提供「净负债率」切换;
- 待补不补零:某个账户余额未知就不参与汇总,但 UI 明确标「已知部分 + 覆盖范围」,而不是当 0 拉低净值;
- 负债进主视图:有未还清负债,首页就显示,不藏二级页;
- 快照是历史事实:每次盘点写一条
{month, A, L},不允许被后来改的口径重算。
趋势、按人归属、按类明细都是在这份快照 + 账户明细上算的派生视图,不额外落库——只有一种真相。

八、接口清单
服务端就 13 个端点,全部 JSON:
| 端点 | 作用 |
|---|---|
GET /api/ledger | 取账本(未解锁只返回骨架) |
GET/POST /api/session、/api/login、/api/logout、/api/unlock、/api/lock | 会话与解锁 |
POST /api/setup | 首次设密码(写 vault) |
POST /api/password | 改密码 |
POST /api/draft | 盘点草稿(未提交不落账本) |
POST /api/verify | 校验一份导入/备份内容 |
POST /api/export、/api/decrypt | 加密导出、解密导入 |
GET /api/log | 审计日志(ledger.json 的 logs 数组) |
九、踩坑记录
| 现象 | 根因 | 修法 |
|---|---|---|
| 数据目录权限是 755,别人的容器/账号能读 | mkdir(..., { mode: 0o700 }) 只在新建时生效;安装脚本已经把目录建好了 | 启动时再 chmod(dataDir, 0o700) 一次,失败不致命 |
| NAS 断电后服务起不来,报「数据目录已被占用」 | 僵硬地认为锁文件存在就等于有进程 | 读 PID 用 process.kill(pid, 0) 判存活,EPERM 视为存活,已死则接管 |
| 导入/恢复把现有账本覆盖后无法回退 | 覆盖前没留副本 | 覆盖式操作先写 .jiadi-keep/ledger-before-<操作>-<时间>.json 到卷根 |
| 老备份升级后解不开 | 换 KDF 时没保留读分支 | 解密端接受 PBKDF2-210000 与 scrypt 两种,加密端只写 scrypt |
| 余额为未知时净值被拉低 | 未知值当时按 0 处理 | 未知不参与汇总,UI 标覆盖范围(「待补不补零」) |
| 官网在 Safari 上点锚点落点偏 300px、深色底上图标看不见 | 吸顶栏高度没参与锚点计算;图标与深色同色 | 锚点偏移按吸顶高度补偿;图标加描边并在深色底上肉眼自检(写进 README 自检项) |
十、发行与取舍
| 形态 | 怎么跑 | 用途 |
|---|---|---|
| fnOS 微应用(fpk) | 装进应用中心,走网关 socket | 家里 NAS 常驻,全家访问 |
| Docker | 自带 compose.yaml + Dockerfile | 任意容器主机 |
| 纯 Node | node server.mjs | 本机试跑,零依赖直接起 |
| 选择 | 替代方案 | 为什么不用 |
|---|---|---|
| 盘点式(只填余额) | 复式记账 / 流水 | 记账成本决定它会不会被坚持用;余额对不上时人自己会去查流水 |
| 零依赖 Node | Express + SQLite + 前端框架 | 家庭应用要活很多年,依赖越少越不容易在某次升级后跑不起来 |
| 单文件账本 + 原子写 | 数据库 | 规模是「几十账户 × 几十次盘点」,文件够用,还能直接看、直接备份 |
| 共享密码 | 多用户 + 权限 | 家里就几个人,多用户是给「防家里人」设计的,方向就错了 |
| 加密导出 | 明文 JSON 导出 | 账本落到群/网盘就是泄露,导出必须默认加密 |
已知限制:没有多用户与权限隔离(共享一个密码);不支持并发多写(单写者锁);趋势只到月/年粒度;只读报告是静态 HTML,无交互。
怎么拿到
- 官网与文档:sunanang.github.io/jiadi
- 安装包(fpk / Docker):github.com/Sunanang/jiadi/releases
- 源码(MIT):github.com/Sunanang/jiadi
- 怎么用(建账 / 盘点 / 备份的完整步骤):家底怎么用:从建账到每月盘点
