Files
OpenClaw Agent 53733b39fc feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示
- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
2026-09-14 14:00:07 +08:00
..

工时记账 · 服务端与同步

数据从「只在浏览器 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)
    │   ├── mcp.js          只读 MCP 端点(JSON-RPC 2.0)+ DB 真开关 + 令牌管理
    │   ├── store.js        存储契约 + 输入校验(含 app_settings 读写)
    │   └── schema.sql      建表语句(SQLite / D1 通用)
    ├── adapters/
    │   ├── sqlite-node.js  VPS:node:sqlite(Node 22 内置,零 npm 依赖)
    │   └── d1.js           Cloudflare D1
    ├── node-server.js      VPS 入口:静态页 + /api/* + /mcp 同一端口
    ├── worker.js           CF Workers 入口
    ├── wrangler.toml       CF 部署配置
    └── test/               api.test.js(72 项)+ mcp.test.js(39 项)

换平台只需换适配器,core/ 一行都不用动。

VPS 部署(当前方式)

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 才开放注册
MCP_AUTH_TOKEN 未设 MCP 端点接入令牌,仅首次种子:库里已有令牌后以 DB 为准(页面后台可改),可不设

首次访问 http://<地址>:9523 → 设置页 → 云端同步 → 创建首个账号。 建首个账号后注册自动关闭,公网暴露也不会被陌生人开号。

跑测试:

cd server && npm test        # 72 项 API + 39 项 MCP
cd .. && node verify_payroll.js   # 220 项

MCP Server(POST /mcp)

服务端内置只读 MCP 端点(Streamable HTTP,JSON-RPC 2.0),让对话式 AI 查询出勤与薪酬。 工具 7 个:get_month_summary / get_shift_records / get_payroll_settings / get_penalty_details / simulate_leave / list_month_overview / get_business_rules。 算钱一律复用 shared/payroll.js 引擎(与前端看板同源),不让 AI 口算。

  • 配置存 DB(app_settings 表),页面「设置 → AI 接入 (MCP)」可开关/生成/重置令牌,即时生效无需重启
  • MCP_AUTH_TOKEN 环境变量只做首启种子(库里从没有过令牌时迁入 DB),不设也行
  • 状态语义:未配置 → 404(端点视为不存在);已配置但关闭 → 503;令牌错误 → 401
  • 管理 API(登录态):GET /api/mcp/status(掩码)、POST /api/mcp/token(生成/重置,明文仅回一次)、GET /api/mcp/token(复制用取回明文)、POST /api/mcp/enabled
  • 只读约束:任何工具都不写库,AI 最多能看,不能改

systemd

现有 work-hours-tracker.service 是 python3 -m http.server 9522,只发静态文件, 且会把 .bak、server/、数据库一并发出去。切到新服务顺带解决这个问题:

[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 部署

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 <token>。

方法 路径 用途
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,与前端「导出备份」同构,可互相导入
GET /api/mcp/status MCP 接入状态(configured/enabled/令牌掩码)
POST /api/mcp/token 生成/重置 MCP 令牌(明文仅此一次,自动启用)
GET /api/mcp/token 取回 MCP 令牌明文(复制用)
POST /api/mcp/enabled 开/关 MCP 端点(即时生效,无需重启)

同步机制

版本号 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,字段会迭代所以不拆列
app_settings key App 级运行时配置(MCP 开关/令牌),不属于任何用户,不参与同步

安全

已做:

  • 密码 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:

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 也只需要写一个入口文件。