家底(家庭总账)

家底(家庭总账)

不记流水、不做预算、不连银行的家庭总账:一次盘点只填余额。Node 22 零第三方依赖,单文件账本 + 原子写 + 加密备份,fnOS 微应用 / Docker / 纯 Node 三种跑法。

November 7, 20262026年11月7日

Read in English →阅读英文版 →

记账软件问「这笔钱花在哪」,家底只问「现在家里有多少钱」。没有一笔笔流水,只有固定周期的盘点。

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.encrypted
v: 1
kdf: scrypt-N32768-r8-p1 | pbkdf2-sha256-210000
cipher: aes-256-gcm
salt: 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任意容器主机
纯 Nodenode server.mjs本机试跑,零依赖直接起
选择替代方案为什么不用
盘点式(只填余额)复式记账 / 流水记账成本决定它会不会被坚持用;余额对不上时人自己会去查流水
零依赖 NodeExpress + SQLite + 前端框架家庭应用要活很多年,依赖越少越不容易在某次升级后跑不起来
单文件账本 + 原子写数据库规模是「几十账户 × 几十次盘点」,文件够用,还能直接看、直接备份
共享密码多用户 + 权限家里就几个人,多用户是给「防家里人」设计的,方向就错了
加密导出明文 JSON 导出账本落到群/网盘就是泄露,导出必须默认加密

已知限制:没有多用户与权限隔离(共享一个密码);不支持并发多写(单写者锁);趋势只到月/年粒度;只读报告是静态 HTML,无交互。

怎么拿到