# TapLedger Server — 家庭共享账本后端 部署在 NAS(Docker)上的自托管后端,为「一点账本」APP 提供多用户注册/登录与家庭公共账本同步。 - 技术栈:Node.js 22 + Express + better-sqlite3(单文件数据库)+ JWT - 零外部依赖:不需要额外的数据库/缓存容器 - 数据安全:全部数据存在 `./data/tapledger.db`,备份该目录即可 ## 一、NAS 部署(Docker Compose,镜像拉取 / 免本地编译) 镜像托管在 GitHub 容器仓库 **GHCR**:`ghcr.io/sctale/tapledger-server`。 NAS 上不再本地编译,只 `pull` 已发布镜像并启动。 ```bash cd server # 1. 首次部署先创建密钥文件(生产务必改成随机长字符串) # cp .env.example .env 然后编辑 .env 填真实 JWT_SECRET # 2. 拉取镜像并启动 docker compose pull docker compose up -d # 查看日志 / 健康检查 docker compose logs -f curl http://:8420/api/health ``` > 更新到新版本:`docker compose pull && docker compose up -d` 即可,无需重新编译。 > 想确认跑的是哪一版:`docker compose logs --tail 20` 会看到 `[boot] TapLedger server v0.5.9 监听 :8420`(v0.5.7 起启动即打印版本号与备份开关;v0.5.9 起 401 也会记 `[auth]` 行)。 > 部署教程:通用命令行版见 [DEPLOY_DOCKER.md](./DEPLOY_DOCKER.md)(HTTPS 反代 / TRUST_PROXY / 备份恢复 / FAQ),群晖图形化版见 [DEPLOY_SYNOLOGY.md](./DEPLOY_SYNOLOGY.md)。 ### ⚠️ 升级到 v0.4.2+ 注意(重要) 自 v0.4.2 起服务端加了防伪造保护,**首次升级到 v0.4.2/v0.4.3/v0.5.0 前请先确认 `.env`**: 1. **必须先配好 `JWT_SECRET` 再升级**。生产镜像(`NODE_ENV=production`)若 `.env` 里的 `JWT_SECRET` **缺失、仍是示例值、短于 16 位或等于内置默认值**,容器会打印 `[fatal] 生产环境必须配置 JWT_SECRET…` 后**拒绝启动**。这是刻意保护(默认密钥下任何人可伪造登录 token 读取全家账本),不是 bug。 - 升级前:`docker compose logs` 若看到上面这行,说明密钥没配好——先在 `.env` 写一个强随机串再 `docker compose up -d`。 - 生成强密钥:`openssl rand -hex 32`(或任意 ≥32 位随机串)填入 `JWT_SECRET=`。 - 该值是登录凭证的签名密钥,**迁移/换机时保持不变**,否则全员需重新登录;切勿提交到任何 git/网盘。 2. **(可选)管理面板**:v0.5.0 起,在 `.env` 加 `ADMIN_TOKEN=<强随机串>` 并重启一次,浏览器开 `http://NAS_IP:8420/admin` 可在线开关「允许新用户注册 / 允许邀请码加入家庭」(即时生效,用于公网暴露时随时熔断陌生人)。不配 `ADMIN_TOKEN` 则该面板不可用,其余功能不受影响。 3. **(可选)自动备份**:v0.4.3 起每天自动热备份到 `data/backups/tapledger-日期.db`(保留 7 份)。升级本身不动数据,但可顺手确认该目录已生成当日备份。 > 回退:改 `docker-compose.yml` 里 `image:` 指回旧版本号 tag 重新拉取即可;数据在 `data/` 卷内,升降级都不丢。 ### 群晖 / 威联通图形化步骤 1. Container Manager / Docker → 项目 → 新建 2. 路径选到本 `server` 目录(含 docker-compose.yml) 3. 在同目录放好 `.env`(含 JWT_SECRET)后启动(首次会从 GHCR 拉镜像) 4. 防火墙放行 8420 端口(仅局域网使用则无需暴露公网) ### 发布新版本镜像(本地构建推送,默认流程) 在装有 Docker 的开发机上执行(tag 自动读 `server/package.json`,推 `<版本号>` + `latest` 双标签): ```powershell powershell -ExecutionPolicy Bypass -File scripts\docker-push.ps1 ``` 脚本完成:GHCR 登录(优先用环境变量 `TAPLEDGER_GHCR_PAT` 里仅含 `write:packages` 的 PAT,否则回落到 `gh auth token`)→ `docker buildx build --platform linux/amd64,linux/arm64` → 推送 `0.x.y` 与 `latest` 双标签 → 结束时 `docker logout`。本机没有 buildx 多架构能力时脚本会**拒绝**单架构推送(那会覆盖多架构清单,导致 arm64 的 NAS 拉不到镜像),确认只需本机架构才加 `-AllowSingleArch`。镜像已设为 **Public**,NAS 无需 `docker login` 直接拉取。 NAS 更新:`docker compose pull && docker compose up -d`。 > 镜像与 APK **一律本地构建**,仓库不再配置任何构建类 GitHub Actions(v0.11.8 起)。APP 侧门禁(类型检查 / ESLint / Prettier / 单测 / 签名注入自检)跑在 `scripts/release-app.ps1` 里。 ### 数据备份 服务端内置**每日自动热备份**(v0.4.3 起,走 SQLite 在线备份 API,无需停服): - 产物:`data/backups/tapledger-YYYY-MM-DD.db`,默认保留最近 7 份(env 可调:`BACKUP_KEEP=份数`;`BACKUP_DISABLED=1` 关闭) - 服务器当天开过机就会补一份当日备份;长期不停机则每 24 小时一份 - 想再稳一层:用 DSM「Hyper Backup」定期整包备份 `data/` 文件夹 **回滚到某个备份点**(如误删数据): ```bash docker compose down cp data/tapledger.db data/tapledger.db.broken # 留出现场 rm -f data/tapledger.db-wal data/tapledger.db-shm cp data/backups/tapledger-2026-09-08.db data/tapledger.db # 换成目标备份 docker compose up -d ``` 手动整包备份仍然可用(停机时最稳): ```bash tar czf tapledger-backup-$(date +%Y%m%d).tar.gz server/data ``` ### HTTPS(可选) APP 直连 `http://NAS_IP:8420` 即可(自托管场景)。如需公网访问,建议用反向代理(Nginx / 群晖反向代理 / Caddy)加 HTTPS,并在 APP 中填 `https://your.domain.com`。 > **走反向代理时请设 `TRUST_PROXY=1`**(v0.5.2 起,写在 `server/.env` 后重启容器)。否则服务端看到的客户端 IP 恒为代理地址,登录/注册限流会按代理 IP 聚合——一人输错密码全家 429。局域网直连无需配置。 ### 管理面板(v0.5.0,可选) 给公网部署准备的一个极简后台,用于**随时熔断陌生人进入**: 1. 在 `server/.env` 增加 `ADMIN_TOKEN=<≥32位随机串>` 并重启一次容器(只有这一步需要重启) 2. 浏览器打开 `https://你的域名/admin`(局域网即 `http://NAS_IP:8420/admin`),粘贴口令连接 3. 可即时切换两个开关(保存到 SQLite,无需再重启): - **允许新用户注册**:关闭后注册接口返回 403——攻击链「注册→爆破邀请码→进家庭」在第一环掐断;家人已注册的账号不受影响,登录照常 - **允许邀请码加入家庭**:二级保险,关闭后即便拿到邀请码也进不去 4. 同页显示只读概览:用户数 / 家庭数 / 有效记录数 / 最近同步时间 安全说明:未配置 `ADMIN_TOKEN` 时面板**页面与接口一律 503**(v0.5.6 起连 HTML 也不返回,此前页面恒定 200 等于公开宣告后台入口存在);口令用 sha256 归一后定值时间比较,并有 10 次/分/IP 限流;面板页本身不含任何敏感数据。勾选「记住口令」才会把口令写进浏览器 localStorage——公共设备别勾。请通过 HTTPS 使用(见上节)。 ## 二、本地开发 ```bash cd server npm install npm run dev # tsx 热重载,默认 :8420 npm run typecheck ``` 环境变量: | 变量 | 默认 | 说明 | |---|---|---| | `PORT` | `8420` | 监听端口 | | `JWT_SECRET` | 开发默认值 | **生产必改**:生产环境缺失/等于内置默认值/短于 16 位则拒绝启动。README 建议 ≥32 位随机串(代码只硬性拦住明显不安全的取值) | | `JWT_EXPIRES` | `365d` | 可选(v0.5.5):登录态有效期,家庭场景默认一年免重登;可设 `30d` 等收紧。改密会让旧 token 立即失效(`users.token_version` 递增) | | `DATA_DIR` | `./data` | 数据库目录 | | `ADMIN_TOKEN` | 空 | 可选(v0.5.0):管理面板口令;不配置则 `/admin` 与 `/api/admin/*` 一律 503 | | `TRUST_PROXY` | 空 | 可选(v0.5.2):经 Nginx/群晖反代时设 `1`,让登录/注册限流按真实客户端 IP 计数,否则全员共用代理 IP 的配额 | | `BACKUP_KEEP` | `7` | 可选(v0.4.3):每日热备份保留份数 | | `BACKUP_DISABLED` | 空 | 可选:设 `1` 关闭自动备份 | ## 三、API 概览 | 方法 | 路径 | 说明 | 认证 | |---|---|---|---| | GET | `/api/health` | 健康检查 `{ok, version, time}`(`version` 自 v0.5.8 起带上,便于远程确认在跑哪一版) | - | | POST | `/api/auth/register` | 注册 `{username, password, displayName?}` | - | | POST | `/api/auth/login` | 登录 `{username, password}` → `{token, user}` | - | | POST | `/api/auth/password` | 改密 `{currentPassword, newPassword}` → `{ok, token}`;成功后其它设备旧 token 立即 401 | Bearer | | GET | `/api/me` | 当前用户信息 | Bearer | | PUT | `/api/me` | 改昵称/头像 `{displayName?, avatarEmoji?}` | Bearer | | DELETE | `/api/me` | 注销账号 `{password}`:级联删个人账本;创建者注销=解散家庭账本(成员解绑、本机副本保留) | Bearer | | POST | `/api/family` | 创建家庭 `{name}`(创建者为 owner) | Bearer | | POST | `/api/family/join` | 邀请码加入 `{inviteCode}` | Bearer | | GET | `/api/family` | 当前家庭信息(含邀请码) | Bearer | | GET | `/api/family/members` | 成员列表 | Bearer | | POST | `/api/family/invite/regenerate` | 重置邀请码(owner) | Bearer | | DELETE | `/api/family/members/:userId` | 移除成员(owner,不可移除自己) | Bearer | | POST | `/api/family/leave` | 退出/解散家庭 | Bearer | | GET | `/api/ledgers` | 列出当前用户可访问的账本(个人 + 家庭) | Bearer | | POST | `/api/sync/pull` | 拉取增量 `{since, ledgerId}` → `{serverTime, changes}` | Bearer | | POST | `/api/sync/push` | 上传变更 `{ledgerId, changes}` → `{applied, skipped, invalid, invalidIds?, rejected, errors?}` | Bearer | ### 个人账本 / 家庭账本 - 注册时自动为用户创建独立「个人账本」(老用户惰性补建),仅本人可读写,与家庭账本数据隔离 - `GET /api/ledgers` 返回该用户可访问的全部账本(`type: personal | family`),客户端据此提供账本切换 - `sync/pull`、`sync/push` 均需携带 `ledgerId`,服务端校验用户对该账本的读写权限;不存在或无权访问返回 403 ### 同步协议(本地优先 + LWW) - 所有业务实体(records / recurring / customCategories)以 **uuid** 为主键,带 `updatedAt`(毫秒时间戳)与 `deleted`(墓碑) - **pull**:返回 `updated_at > since` 的全部变更(含墓碑),客户端按 LWW 合并到本地 - **push**:服务端逐条 upsert,仅当 `incoming.updated_at > 服务端 updated_at` **且该 uuid 原本就属于这本账本**才覆盖 - `family_id` 条件是 v0.5.6 补的:uuid 是全局主键,缺它时任何登录用户都能把字段写进别人账本的同 uuid 记录(SET 子句不动 family_id,数据留在原账本 → 跨账本篡改) - `user_id`(记账人)只在插入时落定,编辑不会把作者改成最后编辑的人 - `updatedAt` 会被钳制到服务端当前时间:设备时钟超前产生的「未来版本号」否则会永久压制后续所有正常编辑 - 未生效的条目分两类:`skipped`(版本不比服务端新 / uuid 属于别的账本,幂等丢弃,无需提示)与 `invalid`(字段超出 zod 限制,**本地已写入但传不上去**,客户端必须如实提示;`invalidIds` 给出样本) - 字段上限:`note` ≤60、`category` ≤40、规则 `name` ≤20、分类 `label` ≤12、`emoji` ≤8、`uuid` 8–64,客户端在录入与导入入口即按此归一(`src/utils/exportData.ts` 的 `SERVER_LIMITS`) - 限流:登录 5/分/IP、注册 3/分/IP、加入家庭 5/分/IP + 同一邀请码错 10 次锁 10 分钟、改密与注销各 5/分/IP ## 四、快速自测(curl) ```bash BASE=http://localhost:8420 # 注册两个家庭成员 curl -s $BASE/api/auth/register -H 'Content-Type: application/json' \ -d '{"username":"dad","password":"123456","displayName":"爸爸"}' curl -s $BASE/api/auth/register -H 'Content-Type: application/json' \ -d '{"username":"mom","password":"123456","displayName":"妈妈"}' # 爸爸创建家庭 → 拿到邀请码 TOKEN_DAD=... # 上一步返回的 token curl -s $BASE/api/family -H "Authorization: Bearer $TOKEN_DAD" \ -H 'Content-Type: application/json' -d '{"name":"我们家"}' # 妈妈用邀请码加入 TOKEN_MOM=... curl -s $BASE/api/family/join -H "Authorization: Bearer $TOKEN_MOM" \ -H 'Content-Type: application/json' -d '{"inviteCode":"XXXXXX"}' # 爸爸 push 一条记录,妈妈 pull 即可看到 # LEDGER_ID:账本 id 由 GET /api/ledgers 取得(个人账本 + 家庭账本各一个),push/pull 都必带 curl -s $BASE/api/sync/push -H "Authorization: Bearer $TOKEN_DAD" \ -H 'Content-Type: application/json' \ -d "{\"ledgerId\":$LEDGER_ID,\"records\":[{\"uuid\":\"r-001\",\"amount\":25,\"category\":\"food\",\"type\":\"expense\",\"note\":\"午饭\",\"date\":\"2026-08-16\",\"timestamp\":1755300000000,\"reimbursable\":0,\"reimbursed\":0,\"updatedAt\":1755300000000,\"deleted\":0}]}" curl -s $BASE/api/sync/pull -H "Authorization: Bearer $TOKEN_MOM" \ -H 'Content-Type: application/json' -d "{\"since\":0,\"ledgerId\":$LEDGER_ID}" ```