# 工时记账 · 服务端与同步 数据从「只在浏览器 localStorage」升级为「服务器 SQLite/D1 + 本地离线缓存」。 本地仍是唯一真相,断网、没登录都照常记账;同步只是额外把数据推到服务器。 ## 目录结构 ``` work-hours-tracker/ ├── index.html 前端(已内置同步面板) ├── sync.js 客户端同步引擎(不依赖 Vue,可复用到 APP 壳) ├── verify_payroll.js 核算算法用例(220 项) └── server/ ├── core/ ← 平台无关,VPS 与 CF Workers 共用 │ ├── api.js HTTP 接口逻辑(收发标准 Request/Response) │ ├── auth.js PBKDF2 密码 + SHA-256 令牌(纯 Web Crypto) │ ├── store.js 存储契约 + 输入校验 │ └── schema.sql 建表语句(SQLite / D1 通用) ├── adapters/ │ ├── sqlite-node.js VPS:node:sqlite(Node 22 内置,零 npm 依赖) │ └── d1.js Cloudflare D1 ├── node-server.js VPS 入口:静态页 + /api/* 同一端口 ├── worker.js CF Workers 入口 ├── wrangler.toml CF 部署配置 └── test/api.test.js 服务端用例(72 项) ``` 换平台只需换适配器,`core/` 一行都不用动。 ## VPS 部署(当前方式) ```bash cd server npm start # 默认 9523,静态页与 API 同源 ``` 环境变量: | 变量 | 默认 | 说明 | |---|---|---| | `PORT` | 9523 | 监听端口 | | `HOST` | 0.0.0.0 | 监听地址 | | `DB_PATH` | `server/data/work-hours.db` | SQLite 文件位置 | | `STATIC_DIR` | 项目根 | 静态目录 | | `ALLOW_REGISTER` | 未设 | 设 `1` 才开放注册 | 首次访问 `http://<地址>:9523` → 设置页 → 云端同步 → 创建首个账号。 **建首个账号后注册自动关闭**,公网暴露也不会被陌生人开号。 跑测试: ```bash cd server && npm test # 72 项 cd .. && node verify_payroll.js # 220 项 ``` ### systemd 现有 `work-hours-tracker.service` 是 `python3 -m http.server 9522`,只发静态文件, 且会把 `.bak`、`server/`、数据库一并发出去。切到新服务顺带解决这个问题: ```ini [Unit] Description=Work Hours Tracker After=network.target [Service] Type=simple User=root WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js Environment=PORT=9523 Restart=always RestartSec=3 [Install] WantedBy=multi-user.target ``` ## Cloudflare Workers 部署 ```bash cd server wrangler d1 create work-hours # 把 database_id 填进 wrangler.toml npm run cf:migrate # 建表 npm run cf:deploy ``` 静态资源走 Workers Assets(`wrangler.toml` 的 `[assets]`),API 走同一个 Worker,仍是同源。 已确认可行:`core/` 只用 `Request`/`Response`/`crypto.subtle`/`TextEncoder`,这些 Workers 全都有; PBKDF2 十万次迭代实测约 17ms,远低于 Workers 的 CPU 时间预算。 ## 接口 除 `/api/health`、`/api/register`、`/api/login` 外都需要 `Authorization: Bearer `。 | 方法 | 路径 | 用途 | |---|---|---| | GET | `/api/health` | 探活,返回用户数(前端据此决定显示「登录」还是「创建首个账号」) | | POST | `/api/register` | 注册(默认仅首个账号) | | POST | `/api/login` | 登录,返回 token 与当前 rev | | POST | `/api/logout` | 令牌立即失效 | | GET | `/api/me` | 当前用户与 rev | | GET | `/api/data` | 全量拉取 | | GET | `/api/changes?since=N` | 增量拉取(含删除墓碑) | | POST | `/api/push` | 推送变更,整批原子写入,返回新 rev | | DELETE | `/api/record/YYYY-MM-DD` | 删单日 | | DELETE | `/api/adjust/YYYY-MM` | 删单月奖惩 | | GET | `/api/export` | 导出 JSON,与前端「导出备份」同构,可互相导入 | ## 同步机制 **版本号 rev。** 每个用户一个单调递增计数器,任何写入都 +1,被改动的行打上这个 rev。 客户端记住上次见过的 rev,拉取时只问「比它新的」,不做全量对比。 **墓碑删除。** 删除不物理删行,而是置 `deleted=1`。否则「这天被删了」这件事没法传播到其他设备—— 对方只会看到自己有、服务器没有,然后又给推回来。 **先推后拉。** 本地改动记在 dirty 集合里,同步时先 push 再 pull,所以本地改动天然赢, 不会被远端旧值覆盖。dirty 里的键在应用远端数据时会被跳过。 **离线优先。** localStorage 始终是本地真相。没登录、断网、服务器挂了都能照常记账, 改动攒在 dirty 里,等能连上再一次推上去。 **设置合并而非覆盖。** 客户端可能只传改动的字段,服务端与现有值合并,不会把没传的清掉。 **首次登录的冲突处理**(`index.html` 的 `doLogin`): 先 `pullAll` 看服务端有没有数据——服务端为空且本地有数据 → 自动 `pushAll` 上传; 否则拉下来合并。不静默丢任何一边。 ## 数据模型 | 表 | 主键 | 说明 | |---|---|---| | `users` | id | 含 `rev`,该用户的全局版本号 | | `sessions` | token_hash | 只存 SHA-256,泄库换不出可用 token | | `records` | (user_id, day) | day = `2026-09-08` | | `month_adjust` | (user_id, month) | month = `2026-09`,`status` = draft/final | | `settings` | user_id | 整份 JSON,字段会迭代所以不拆列 | ## 安全 已做: - 密码 PBKDF2-SHA256 十万次迭代 + 随机盐,不存明文 - 令牌只存哈希,登出立即失效,30 天过期 - 登录/注册限流(每 IP 每分钟 10 次) - 用户名不存在时也走一次真实密码校验开销,防止靠响应时间枚举用户名 - 首个账号建立后自动关闭注册 - 逐用户数据隔离(已有用例验证两账号互不可见) - 静态服务屏蔽 `server/`、`.trash/`、`png/`、`.bak`、`.db`、`.sql`、`verify_*.js`,防目录穿越 - 服务端输入校验:非法状态/班次/工时/件数、`2026-13`、`2026-02-31` 这类假日期全部拒收 未做,公网暴露前需要补: - **HTTPS**。当前 HTTP 明文,密码与令牌在链路上可见。上公网必须配反代 + 证书,或直接用 CF Workers(自带 HTTPS)。 - 令牌存在 localStorage,XSS 可窃取。当前页面无用户生成内容渲染,风险低。 - 无二次验证、无密码找回。忘记密码只能删库重建。 ## 前端接入点 `sync.js` 暴露 `window.WHTSync`: ```js WHTSync.setApplyHook(fn) // 注册「如何把远端数据写进本地」 WHTSync.markDirty(kind, key) // kind: 'record' | 'adjust' | 'settings' WHTSync.syncNow() // 手动同步 WHTSync.pullAll() // 服务器覆盖本地 WHTSync.pushAll() // 本地全量上传 WHTSync.onChange(fn) // 订阅状态变化 WHTSync.setApiBase(url) // 跨域部署时指定 API 地址,留空 = 同源 ``` `index.html` 里 `persist(dirtyDays)` / `persistAdjust(dirtyMonths)` 接收改动的键并自动标脏, 1.5 秒防抖后上传。回到前台、网络恢复时各补一次同步。 `suppressSync` 标志用于远端数据写回本地时关闭标脏,避免「拉下来 → 又当成本地改动推回去」的回环。 ## 做 APP 时 `sync.js` 只依赖 `localStorage` 与 `fetch`,不碰 Vue 也不碰浏览器独有 API。 套 Capacitor / WebView / React Native 时这一层可整块复用,把 `setApiBase()` 指向服务器即可。 `core/` 已经是平台无关的,换 Bun/Deno/其他 runtime 也只需要写一个入口文件。