feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示

- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
This commit is contained in:
OpenClaw Agent
2026-09-14 14:00:07 +08:00
parent ad36640331
commit 53733b39fc
10 changed files with 320 additions and 63 deletions
+7 -5
View File
@@ -57,8 +57,8 @@
额度用尽后转纯后台轮询。技术日志仅输出到控制台(`[boot-guard]` 前缀)。 额度用尽后转纯后台轮询。技术日志仅输出到控制台(`[boot-guard]` 前缀)。
- `shared/payroll.js`:独立同构薪酬计算模块(包含时薪折算、截断规则、周期合并等 30+ 纯函数)。 - `shared/payroll.js`:独立同构薪酬计算模块(包含时薪折算、截断规则、周期合并等 30+ 纯函数)。
- `sync.js`:本地优先增量同步适配层。 - `sync.js`:本地优先增量同步适配层。
- `server/`:轻量级 REST API 服务端(支持鉴权、版本同步、健康检查 `/api/health`), - `server/`:轻量级 REST API 服务端(支持多用户鉴权、版本同步、健康检查 `/api/health`),
并内置只读 MCP 端点(`POST /mcp`):AI 客户端可查询出勤与薪酬,配置在页面「设置 → AI 接入 (MCP)」管理,开关/令牌即时生效。 并内置只读 MCP 端点(`POST /mcp`):AI 客户端可查询出勤与薪酬。支持多用户独立 MCP 令牌隔离,配置在页面「设置 → AI 接入 (MCP)」管理,开关/令牌即时生效。
- `.build/`:Tailwind CSS 预编译工作区(只在开发机跑,产物是 `vendor/tailwind.css`,运行时不需要它)。 - `.build/`:Tailwind CSS 预编译工作区(只在开发机跑,产物是 `vendor/tailwind.css`,运行时不需要它)。
**改 `index.html` 里出现的新 Tailwind class 后必须重跑**:`cd .build && npx tailwindcss -i input.css -o ../vendor/tailwind.css --minify`, **改 `index.html` 里出现的新 Tailwind class 后必须重跑**:`cd .build && npx tailwindcss -i input.css -o ../vendor/tailwind.css --minify`,
否则新样式不在预编译产物里,页面静默丢样式(2026-09-13 MCP 开关按钮不可见即此因)。 否则新样式不在预编译产物里,页面静默丢样式(2026-09-13 MCP 开关按钮不可见即此因)。
@@ -103,7 +103,7 @@ systemctl enable --now work-hours-tracker.service
``` ```
单元文件关键内容(端口固定 9522:localStorage 按 origin 隔离,换端口 = 换 origin, 单元文件关键内容(端口固定 9522:localStorage 按 origin 隔离,换端口 = 换 origin,
已有打卡数据会看不见,勿改): 已有打卡数据会看不见,勿改;如需开启多用户注册,可添加 `Environment=ALLOW_REGISTER=1`):
```ini ```ini
[Unit] [Unit]
Description=Work Hours Tracker (Node: static + sync API) Description=Work Hours Tracker (Node: static + sync API)
@@ -116,6 +116,7 @@ WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js
Environment=PORT=9522 Environment=PORT=9522
Environment=HOST=0.0.0.0 Environment=HOST=0.0.0.0
# Environment=ALLOW_REGISTER=1 # 开放多用户自适应注册(首个用户创建后如需继续允许新账号注册可启用)
Restart=always Restart=always
RestartSec=3 RestartSec=3
@@ -171,9 +172,10 @@ curl -s http://127.0.0.1:9522/api/health
- **工具 7 个**(全部只读,不写库):`get_month_summary` / `get_shift_records` / `get_payroll_settings` / - **工具 7 个**(全部只读,不写库):`get_month_summary` / `get_shift_records` / `get_payroll_settings` /
`get_penalty_details` / `simulate_leave` / `list_month_overview` / `get_business_rules` `get_penalty_details` / `simulate_leave` / `list_month_overview` / `get_business_rules`
- **配置存 DB**(`app_settings` 表),页面「设置 → AI 接入 (MCP)」可开关/生成/重置令牌,**即时生效无需重启** - **配置存 DB**(`user_mcp_tokens` 表,用户级物理隔离),页面「设置 → AI 接入 (MCP)」可开关/生成/重置各自令牌,**即时生效无需重启**
- **多用户隔离**:各用户 AI 客户端凭借专属 Bearer Token 鉴权,仅查自身出勤打卡与薪酬概况,互不干扰
- `MCP_AUTH_TOKEN` 环境变量只做首启种子(库里从没有过令牌时迁入 DB),不设也行 - `MCP_AUTH_TOKEN` 环境变量只做首启种子(库里从没有过令牌时迁入 DB),不设也行
- 状态语义:未配置 → `404`(端点视为不存在);已配置但关闭 → `503`;令牌错误 → `401` - 状态语义:未配置 → `404`(端点视为不存在);已配置但关闭 → `503`;令牌错误 → `401`
- 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、 - 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、
`GET /api/mcp/token`(复制用取回明文)、`POST /api/mcp/enabled` `GET /api/mcp/token`(复制用取回明文)、`POST /api/mcp/enabled`
- 测试:`cd server && node --disable-warning=ExperimentalWarning test/mcp.test.js`(39 项) - 测试:`cd server && node --disable-warning=ExperimentalWarning test/mcp.test.js`(47 项,含多用户隔离验证)
+15 -4
View File
@@ -429,7 +429,6 @@
<span class="font-semibold text-slate-600">合计应得</span> <span class="font-semibold text-slate-600">合计应得</span>
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(earnedTotal) }}</span> <span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(earnedTotal) }}</span>
</div> </div>
<p v-if="!monthStats.finalized" class="text-[10px] text-slate-400 leading-relaxed pt-1">查获在本月只作记录(当前累计 <b class="text-slate-500 font-mono-num">{{ signed(monthStats.rawReview) }}</b>);须次月初组长审核、扣除漏查与其他扣款后提交定稿才算奖金。未定稿一律按 ¥0 计入应得,与未达 {{ settings.fullDays }} 天不计满勤奖同一个道理。</p>
</div> </div>
<!-- 两套口径之间唯一的桥:明说挣得的钱里哪一部分已发、哪一部分要结转。 <!-- 两套口径之间唯一的桥:明说挣得的钱里哪一部分已发、哪一部分要结转。
没有这一行,用户看到挣得 ≠ 进账就会懿。 --> 没有这一行,用户看到挣得 ≠ 进账就会懿。 -->
@@ -645,6 +644,10 @@
<p v-if="monthStats.missedCount > 0" class="text-slate-600"> <p v-if="monthStats.missedCount > 0" class="text-slate-600">
本月 {{ monthStats.missedCount }} 次漏查,将扣 <b class="text-rose-600">{{ money(monthStats.missedPenalty) }}</b>,约等于白干 {{ fmt(monthStats.missedPenalty / settings.rate) }} 小时。 本月 {{ monthStats.missedCount }} 次漏查,将扣 <b class="text-rose-600">{{ money(monthStats.missedPenalty) }}</b>,约等于白干 {{ fmt(monthStats.missedPenalty / settings.rate) }} 小时。
</p> </p>
<p v-if="monthStats.totalHours > 0" class="text-slate-600">
综合时薪 <b class="text-indigo-600 font-mono-num font-bold">¥{{ effectiveHourly }}</b> /h
<span class="text-slate-400">(合计应得 {{ money(earnedTotal) }} ÷ 总工时 {{ fmt(monthStats.totalHours) }}h,含满勤奖与审核定稿)</span>
</p>
</template> </template>
</div> </div>
</div> </div>
@@ -792,13 +795,14 @@
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" /> class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<div class="flex gap-2 pt-0.5"> <div class="flex gap-2 pt-0.5">
<button @click="doLogin" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">登录</button> <button @click="doLogin" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">登录</button>
<button v-if="sync.serverUsers === 0" @click="doRegister" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">创建首个账号</button> <button v-if="sync.allowRegister" @click="doRegister" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号' : '注册新账号' }}</button>
</div> </div>
</div> </div>
<p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70" <p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70"
:class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'"> :class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'">
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template> <template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template> <template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template>
<template v-else-if="sync.allowRegister">服务端已开启多用户注册,填写用户名密码即可直接创建账号。</template>
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template> <template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
</p> </p>
<label class="flex items-center justify-between gap-2 px-5 py-3"> <label class="flex items-center justify-between gap-2 px-5 py-3">
@@ -1374,6 +1378,12 @@ createApp({
// 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。 // 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。
const earnedFloored = computed(() => flows.value.floored); const earnedFloored = computed(() => flows.value.floored);
// 综合时薪(含满勤与定稿奖惩后的实际每小时劳动收益)
const effectiveHourly = computed(() => {
const hrs = monthStats.value.totalHours;
return hrs > 0 ? (earnedTotal.value / hrs).toFixed(2) : '0.00';
});
const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx())); const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx()));
const recordCount = computed(() => Object.keys(records.value).length); const recordCount = computed(() => Object.keys(records.value).length);
@@ -1720,7 +1730,7 @@ createApp({
// ===== 云端同步(离线优先:本地 localStorage 仍是唯一真相,同步只是额外把数据推到服务器)===== // ===== 云端同步(离线优先:本地 localStorage 仍是唯一真相,同步只是额外把数据推到服务器)=====
const sync = reactive({ const sync = reactive({
status: 'idle', user: null, pending: 0, lastSyncAt: null, lastError: '', status: 'idle', user: null, pending: 0, lastSyncAt: null, lastError: '',
serverUsers: null, apiBase: '', busy: false, hold: null serverUsers: null, allowRegister: null, apiBase: '', busy: false, hold: null
}); });
const syncPanelOpen = ref(false); const syncPanelOpen = ref(false);
const loginForm = ref({ username: '', password: '' }); const loginForm = ref({ username: '', password: '' });
@@ -2072,6 +2082,7 @@ createApp({
sync.lastSyncAt = s.lastSyncAt; sync.lastSyncAt = s.lastSyncAt;
sync.lastError = s.lastError; sync.lastError = s.lastError;
sync.serverUsers = s.serverUsers; sync.serverUsers = s.serverUsers;
sync.allowRegister = s.allowRegister;
sync.hold = s.hold; sync.hold = s.hold;
}); });
sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase(); sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase();
@@ -2106,7 +2117,7 @@ createApp({
copied, dataCopied, toast, copied, dataCopied, toast,
monthStats, prevStats, prevLabel, nextLabel, monthStats, prevStats, prevLabel, nextLabel,
payoutThisA, payoutThisB, payoutNextB, penaltyDetails, payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed, earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed, effectiveHourly,
fullDaysLeft, fullDaysLeft,
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport, fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge, isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
+58
View File
@@ -156,4 +156,62 @@ export class D1Store {
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).bind(key, value, new Date().toISOString()).run(); `).bind(key, value, new Date().toISOString()).run();
} }
// ---------- 用户级 MCP 接入令牌 ----------
async countActiveMcpTokens() {
const r = await this.db.prepare('SELECT COUNT(*) AS c FROM user_mcp_tokens WHERE enabled = 1').first();
return r ? r.c : 0;
}
async getUserMcpConfig(userId) {
const r = await this.db.prepare('SELECT * FROM user_mcp_tokens WHERE user_id = ?').bind(userId).first();
if (!r) return { configured: false, enabled: false, token: null };
return {
configured: true,
enabled: r.enabled !== 0,
token: r.token
};
}
async saveUserMcpToken(userId, token, tokenHash) {
const now = new Date().toISOString();
await this.db.prepare(`
INSERT INTO user_mcp_tokens (user_id, token, token_hash, enabled, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
token = excluded.token,
token_hash = excluded.token_hash,
enabled = 1,
updated_at = excluded.updated_at
`).bind(userId, token, tokenHash, now, now).run();
}
async setUserMcpEnabled(userId, enabled) {
const now = new Date().toISOString();
await this.db.prepare(`
UPDATE user_mcp_tokens SET enabled = ?, updated_at = ? WHERE user_id = ?
`).bind(enabled ? 1 : 0, now, userId).run();
}
async getUserByMcpTokenHash(tokenHash) {
const r = await this.db.prepare(`
SELECT u.*, m.enabled AS mcp_enabled
FROM user_mcp_tokens m
JOIN users u ON u.id = m.user_id
WHERE m.token_hash = ?
`).bind(tokenHash).first();
if (!r) return null;
return {
user: {
id: r.id,
username: r.username,
pw_hash: r.pw_hash,
pw_salt: r.pw_salt,
pw_iter: r.pw_iter,
rev: r.rev,
created_at: r.created_at
},
enabled: r.mcp_enabled !== 0
};
}
} }
+58
View File
@@ -188,4 +188,62 @@ export class SqliteStore {
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).run(key, value, new Date().toISOString()); `).run(key, value, new Date().toISOString());
} }
// ---------- 用户级 MCP 接入令牌 ----------
async countActiveMcpTokens() {
const r = this.db.prepare('SELECT COUNT(*) AS c FROM user_mcp_tokens WHERE enabled = 1').get();
return r ? r.c : 0;
}
async getUserMcpConfig(userId) {
const r = this.db.prepare('SELECT * FROM user_mcp_tokens WHERE user_id = ?').get(userId);
if (!r) return { configured: false, enabled: false, token: null };
return {
configured: true,
enabled: r.enabled !== 0,
token: r.token
};
}
async saveUserMcpToken(userId, token, tokenHash) {
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO user_mcp_tokens (user_id, token, token_hash, enabled, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
token = excluded.token,
token_hash = excluded.token_hash,
enabled = 1,
updated_at = excluded.updated_at
`).run(userId, token, tokenHash, now, now);
}
async setUserMcpEnabled(userId, enabled) {
const now = new Date().toISOString();
this.db.prepare(`
UPDATE user_mcp_tokens SET enabled = ?, updated_at = ? WHERE user_id = ?
`).run(enabled ? 1 : 0, now, userId);
}
async getUserByMcpTokenHash(tokenHash) {
const r = this.db.prepare(`
SELECT u.*, m.enabled AS mcp_enabled
FROM user_mcp_tokens m
JOIN users u ON u.id = m.user_id
WHERE m.token_hash = ?
`).get(tokenHash);
if (!r) return null;
return {
user: {
id: r.id,
username: r.username,
pw_hash: r.pw_hash,
pw_salt: r.pw_salt,
pw_iter: r.pw_iter,
rev: r.rev,
created_at: r.created_at
},
enabled: r.mcp_enabled !== 0
};
}
} }
+14 -8
View File
@@ -6,7 +6,7 @@ import {
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
} from './auth.js'; } from './auth.js';
import { import {
readMcpConfig, saveMcpToken, setMcpEnabled, newMcpToken, maskToken readUserMcpConfig, saveUserMcpToken, setUserMcpEnabled, newMcpToken, maskToken
} from './mcp.js'; } from './mcp.js';
import { import {
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings, BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
@@ -102,7 +102,13 @@ export async function handleApi(request, store, opts = {}) {
try { try {
// ---------- 健康检查 ---------- // ---------- 健康检查 ----------
if (path === '/api/health' && method === 'GET') { if (path === '/api/health' && method === 'GET') {
return json({ ok: true, api: API_VERSION, users: await store.countUsers(), now: new Date().toISOString() }); return json({
ok: true,
api: API_VERSION,
users: await store.countUsers(),
allowRegister: !opts.allowRegister ? (await store.countUsers()) === 0 : true,
now: new Date().toISOString()
});
} }
// ---------- 注册 ---------- // ---------- 注册 ----------
@@ -238,13 +244,13 @@ export async function handleApi(request, store, opts = {}) {
} }
// ---------- MCP 接入管理(登录态即可操作;单人系统无特权分级) ---------- // ---------- MCP 接入管理(登录态即可操作;单人系统无特权分级) ----------
// 开关与令牌存 DB,改动即时生效;令牌明文只在 POST /api/mcp/token 的响应里出现一次。 // 开关与令牌存 user_mcp_tokens 表,每个用户独立隔离;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
if (path.startsWith('/api/mcp/')) { if (path.startsWith('/api/mcp/')) {
await requireAuth(store, request); const user = await requireAuth(store, request);
const sub = path.slice('/api/mcp/'.length); const sub = path.slice('/api/mcp/'.length);
if (sub === 'status' && method === 'GET') { if (sub === 'status' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken); const cfg = await readUserMcpConfig(store, user.id, opts.mcpSeedToken);
return json({ return json({
ok: true, configured: cfg.configured, enabled: cfg.enabled, ok: true, configured: cfg.configured, enabled: cfg.enabled,
masked: cfg.configured ? maskToken(cfg.token) : null masked: cfg.configured ? maskToken(cfg.token) : null
@@ -252,18 +258,18 @@ export async function handleApi(request, store, opts = {}) {
} }
if (sub === 'token' && method === 'POST') { if (sub === 'token' && method === 'POST') {
const token = newMcpToken(); const token = newMcpToken();
await saveMcpToken(store, token); await saveUserMcpToken(store, user.id, token);
return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' }); return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' });
} }
if (sub === 'token' && method === 'GET') { if (sub === 'token' && method === 'GET') {
const cfg = await readMcpConfig(store, opts.mcpSeedToken); const cfg = await readUserMcpConfig(store, user.id, opts.mcpSeedToken);
if (!cfg.configured) return err('尚未配置 MCP 令牌,请先生成', 404); if (!cfg.configured) return err('尚未配置 MCP 令牌,请先生成', 404);
return json({ ok: true, token: cfg.token }); return json({ ok: true, token: cfg.token });
} }
if (sub === 'enabled' && method === 'POST') { if (sub === 'enabled' && method === 'POST') {
const body = await readJson(request); const body = await readJson(request);
if (typeof body.enabled !== 'boolean') return err('enabled 必须是布尔值'); if (typeof body.enabled !== 'boolean') return err('enabled 必须是布尔值');
await setMcpEnabled(store, body.enabled); await setUserMcpEnabled(store, user.id, body.enabled);
return json({ ok: true, enabled: body.enabled }); return json({ ok: true, enabled: body.enabled });
} }
return err('接口不存在: ' + path, 404); return err('接口不存在: ' + path, 404);
+69 -30
View File
@@ -27,6 +27,7 @@
import { timingSafeEqual } from 'node:crypto'; import { timingSafeEqual } from 'node:crypto';
import payroll from '../../shared/payroll.js'; import payroll from '../../shared/payroll.js';
import { hashToken } from './auth.js';
const SERVER_INFO = { name: 'work-hours-tracker-mcp', version: '1.0.0' }; const SERVER_INFO = { name: 'work-hours-tracker-mcp', version: '1.0.0' };
// 与 MCP 官方已发布版本对齐;客户端发来的版本若认得就原样回,否则回兜底版本。 // 与 MCP 官方已发布版本对齐;客户端发来的版本若认得就原样回,否则回兜底版本。
@@ -170,32 +171,47 @@ export function newMcpToken() {
} }
/** /**
* 读生效配置:{ configured, enabled, token }。 * 读指定用户的生效配置:{ configured, enabled, token }。
* 状态语义刻意分开:未配置 → 端点 404(视为不存在);已配置但关闭 → 503 * 兼容旧数据与种子:如果 user_mcp_tokens 查无记录,但 app_settings/seedToken 中存在旧 token,
* (「配了但被关了,可从后台重新打开」);令牌错 → 401。 * 则自动为 id=1 的老用户迁入,保证向后无感兼容。
* @param {object} store
* @param {string} seedToken env 里的首启令牌;入口层传入,core 不直接读 process.env(保持平台无关)
*/ */
export async function readMcpConfig(store, seedToken = '') { export async function readUserMcpConfig(store, userId, seedToken = '') {
let token = await store.getSetting(MCP_KEYS.token); let cfg = await store.getUserMcpConfig(userId);
if (!token && seedToken) { if (cfg && cfg.configured) return cfg;
// 首次种子:env 有令牌且库里从没有过 → 迁入 DB(兼容旧部署,现有 token 无感切换)
await store.setSetting(MCP_KEYS.token, seedToken); // 兼容老单人自用历史数据迁移
token = seedToken; if (userId === 1) {
let legacyToken = await store.getSetting(MCP_KEYS.token);
if (!legacyToken && seedToken) {
legacyToken = seedToken;
await store.setSetting(MCP_KEYS.token, legacyToken);
} }
if (!token) return { configured: false, enabled: false, token: null }; if (legacyToken) {
const enabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0'; const legacyEnabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
return { configured: true, enabled, token }; const tokenHash = await hashToken(legacyToken);
await store.saveUserMcpToken(1, legacyToken, tokenHash);
if (!legacyEnabled) {
await store.setUserMcpEnabled(1, false);
}
return {
configured: true,
enabled: legacyEnabled,
token: legacyToken
};
}
}
return { configured: false, enabled: false, token: null };
} }
/** 生成/轮换令牌并自动启用;明文只应出现在本次调用方的响应里,DB 之外不留副本 */ /** 生成/轮换用户专有令牌并自动启用 */
export async function saveMcpToken(store, token) { export async function saveUserMcpToken(store, userId, token) {
await store.setSetting(MCP_KEYS.token, token); const tokenHash = await hashToken(token);
await store.setSetting(MCP_KEYS.enabled, '1'); await store.saveUserMcpToken(userId, token, tokenHash);
} }
export async function setMcpEnabled(store, enabled) { export async function setUserMcpEnabled(store, userId, enabled) {
await store.setSetting(MCP_KEYS.enabled, enabled ? '1' : '0'); await store.setUserMcpEnabled(userId, enabled);
} }
// ---------- 参数校验 ---------- // ---------- 参数校验 ----------
@@ -533,28 +549,52 @@ async function handleRpcMessage(msg, store, userId) {
/** /**
* HTTP 入口。 * HTTP 入口。
* 用户定位:MCP token 与站点登录态无关;当前是单人自用系统,固定取 id=1 的首个用户 * 多用户架构:MCP 请求由 Bearer Token 鉴权,根据 tokenHash 检索绑定的 user。
* (与 /api/health 的 users:1 对齐)。多人化时这里要换成 token→user 映射。 * 完全解绑单人 id=1 硬编码,不同用户的 AI 客户端独立查各自打卡与薪酬。
* @param {Request} request * @param {Request} request
* @param {object} store SqliteStore / D1Store 实例 * @param {object} store SqliteStore / D1Store 实例
* @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用) * @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用)
*/ */
export async function handleMcp(request, store, opts = {}) { export async function handleMcp(request, store, opts = {}) {
const cfg = await readMcpConfig(store, opts.seedToken); // 全局若未配置过任何可用 token,端点按设计直接关闭(404 隐藏端点)
if (!cfg.configured) { const activeCount = await store.countActiveMcpTokens();
let legacyCfg1 = null;
if (activeCount === 0) {
legacyCfg1 = await readUserMcpConfig(store, 1, opts.seedToken);
if (!legacyCfg1.configured) {
return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 }); return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 });
} }
if (!cfg.enabled) {
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
} }
if (request.method !== 'POST') { if (request.method !== 'POST') {
return new Response('Method Not Allowed(本端点只接受 POST)', { return new Response('Method Not Allowed(本端点只接受 POST)', {
status: 405, headers: { allow: 'POST' } status: 405, headers: { allow: 'POST' }
}); });
} }
const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, ''); const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');
if (!provided || !tokenEquals(provided, cfg.token)) { if (!provided) {
return json({ error: 'unauthorized' }, 401); return json({ error: 'unauthorized', message: '缺少 Authorization Bearer 令牌' }, 401);
}
const tokenHash = await hashToken(provided);
let hit = await store.getUserByMcpTokenHash(tokenHash);
// 兜底兼容迁移:若尚未迁入 user_mcp_tokens,检查 user 1 是否有遗留配置
if (!hit) {
const cfg1 = legacyCfg1 || await readUserMcpConfig(store, 1, opts.seedToken);
if (cfg1.configured && cfg1.token && tokenEquals(provided, cfg1.token)) {
const u1 = await store.getUserById(1);
if (u1) hit = { user: u1, enabled: cfg1.enabled };
}
}
if (!hit) {
return json({ error: 'unauthorized', message: '无效的 MCP 访问令牌' }, 401);
}
if (!hit.enabled) {
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
} }
let body; let body;
@@ -564,8 +604,7 @@ export async function handleMcp(request, store, opts = {}) {
return json(rpcError(null, -32700, 'JSON 解析失败'), 400); return json(rpcError(null, -32700, 'JSON 解析失败'), 400);
} }
const user = await store.getUserById(1); const user = hit.user;
if (!user) return json(rpcError(null, -32603, '系统中还没有用户'), 500);
if (Array.isArray(body)) { if (Array.isArray(body)) {
const responses = []; const responses = [];
+11 -10
View File
@@ -66,20 +66,21 @@ CREATE TABLE IF NOT EXISTS settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。 -- App 级键值:服务自身的运行时配置(保留用于全局系统配置)
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议,
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。 -- 用户级 MCP Token 表:支持多用户独立配置 AI 接入令牌
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议, CREATE TABLE IF NOT EXISTS user_mcp_tokens (
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。 user_id INTEGER PRIMARY KEY REFERENCES users(id),
CREATE TABLE IF NOT EXISTS app_settings ( token TEXT NOT NULL,
key TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE,
value TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_user_mcp_hash ON user_mcp_tokens(token_hash);
+8 -1
View File
@@ -25,10 +25,17 @@
// // 全量导出,用于备份与首次同步 // // 全量导出,用于备份与首次同步
// fullDump(userId): Promise<{ rev, records, adjust, settings }> // fullDump(userId): Promise<{ rev, records, adjust, settings }>
// //
// // ---- App 级键值 ----(运行时配置,如 MCP 开关/令牌;不属于任何用户,不参与同步) // // ---- App 级键值 ----(运行时配置,如全局系统开关;不属于任何用户,不参与同步)
// getSetting(key): Promise<string|null> // getSetting(key): Promise<string|null>
// setSetting(key, value): Promise<void> // setSetting(key, value): Promise<void>
// //
// // ---- 用户级 MCP 接入令牌 ----
// countActiveMcpTokens(): Promise<number>
// getUserMcpConfig(userId): Promise<{ configured: boolean, enabled: boolean, token: string|null }|null>
// saveUserMcpToken(userId, token, tokenHash): Promise<void>
// setUserMcpEnabled(userId, enabled): Promise<void>
// getUserByMcpTokenHash(tokenHash): Promise<{ user: UserRow, enabled: boolean }|null>
//
// UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at } // UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at }
export const HOUR_MIN = 6; export const HOUR_MIN = 6;
+47 -2
View File
@@ -39,12 +39,12 @@ async function rpc(msg, token) {
// 管理 API:与 api.test.js 同一套调用模式 // 管理 API:与 api.test.js 同一套调用模式
let TOKEN = null; let TOKEN = null;
async function call(method, path, body, token = TOKEN) { async function call(method, path, body, token = TOKEN, opts = { allowRegister: true }) {
const headers = { 'content-type': 'application/json' }; const headers = { 'content-type': 'application/json' };
if (token) headers.authorization = `Bearer ${token}`; if (token) headers.authorization = `Bearer ${token}`;
const res = await handleApi(new Request('http://t' + path, { const res = await handleApi(new Request('http://t' + path, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body) method, headers, body: body === undefined ? undefined : JSON.stringify(body)
}), store, {}); }), store, opts);
let json = null; let json = null;
try { json = await res.json(); } catch {} try { json = await res.json(); } catch {}
return { status: res.status, body: json }; return { status: res.status, body: json };
@@ -168,6 +168,51 @@ section('Token 轮换:旧的立即失效');
eq('取回明文是新 Token', r.body.token, globalThis.__T2); eq('取回明文是新 Token', r.body.token, globalThis.__T2);
} }
// ==========================================================
section('多用户 MCP 隔离:不同 Token 独立绑定不同用户');
{
// 注册第二个用户 user2
let r = await call('POST', '/api/register', { username: 'alice', password: 'alice-pass-123' }, null, { allowRegister: true });
eq('第二个用户注册 201', r.status, 201);
r = await call('POST', '/api/login', { username: 'alice', password: 'alice-pass-123' }, null);
eq('第二个用户登录 200', r.status, 200);
const user2Token = r.body.token;
// 用户2 推送自己的打卡数据(9/10 10小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 10 }
}
}, user2Token);
// 用户1 推送自己的打卡数据(9/10 6小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 6 }
}
}, TOKEN);
// 用户2 生成自己的 MCP Token
r = await call('POST', '/api/mcp/token', undefined, user2Token);
eq('用户2 生成 MCP Token 200', r.status, 200);
const user2McpToken = r.body.token;
ok('用户2 的 MCP Token 与用户1 不同', user2McpToken !== globalThis.__T2);
// 用 用户1 的 MCP Token 查 9月总结
const CALL_SUMMARY = {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'get_month_summary', arguments: { year: 2026, month: 9 } }
};
const r1 = await rpc(CALL_SUMMARY, globalThis.__T2);
eq('用户1 MCP 调用成功', r1.status, 200);
eq('用户1 工时是 6h', r1.body.result.structuredContent.hours.total, 6);
// 用 用户2 的 MCP Token 查 9月总结
const r2 = await rpc(CALL_SUMMARY, user2McpToken);
eq('用户2 MCP 调用支持成功', r2.status, 200);
eq('用户2 工时是 10h', r2.body.result.structuredContent.hours.total, 10);
}
// ========================================================== // ==========================================================
store.close(); store.close();
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
+31 -1
View File
@@ -21,6 +21,7 @@
settings: 'work_settings', settings: 'work_settings',
token: 'work_sync_token', token: 'work_sync_token',
user: 'work_sync_user', user: 'work_sync_user',
lastUser: 'work_sync_last_user',
rev: 'work_sync_rev', rev: 'work_sync_rev',
dirty: 'work_sync_dirty', dirty: 'work_sync_dirty',
base: 'work_sync_base', base: 'work_sync_base',
@@ -68,7 +69,9 @@
// 必须落 localStorage:否则用户选了「先不处理」后一刷新,阀就没了, // 必须落 localStorage:否则用户选了「先不处理」后一刷新,阀就没了,
// 下一次 syncNow 会把远端值静默盖到本机冲突日上。 // 下一次 syncNow 会把远端值静默盖到本机冲突日上。
hold: readLS(LS.hold, null), hold: readLS(LS.hold, null),
serverUsers: null // health 探测到的服务端用户数,用于判断该显示「注册」还是「登录」 serverUsers: null, // health 探测到的服务端用户数
allowRegister: null, // 是否开放注册
lastUser: readLS(LS.lastUser, null)
}; };
const dirty = loadDirty(); const dirty = loadDirty();
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0); state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
@@ -404,10 +407,12 @@
try { try {
const h = await api('/api/health', { auth: false, timeoutMs: 6000 }); const h = await api('/api/health', { auth: false, timeoutMs: 6000 });
state.serverUsers = h.users; state.serverUsers = h.users;
state.allowRegister = !!h.allowRegister;
emit(); emit();
return h; return h;
} catch (e) { } catch (e) {
state.serverUsers = null; state.serverUsers = null;
state.allowRegister = null;
emit(); emit();
throw e; throw e;
} }
@@ -422,8 +427,10 @@
const r = await api('/api/login', { method: 'POST', auth: false, body: { username, password } }); const r = await api('/api/login', { method: 'POST', auth: false, body: { username, password } });
state.token = r.token; state.token = r.token;
state.user = r.user; state.user = r.user;
state.lastUser = r.user;
writeLS(LS.token, r.token); writeLS(LS.token, r.token);
writeLS(LS.user, r.user); writeLS(LS.user, r.user);
writeLS(LS.lastUser, r.user);
// 换账号/首次登录:从 0 开始拉全量,避免沿用上一个账号的 rev 导致漏数据 // 换账号/首次登录:从 0 开始拉全量,避免沿用上一个账号的 rev 导致漏数据
state.rev = 0; state.rev = 0;
writeLS(LS.rev, 0); writeLS(LS.rev, 0);
@@ -441,10 +448,33 @@
if (!keepLocal) { if (!keepLocal) {
localStorage.removeItem(LS.records); localStorage.removeItem(LS.records);
localStorage.removeItem(LS.adjust); localStorage.removeItem(LS.adjust);
localStorage.removeItem(LS.dirty);
localStorage.removeItem(LS.lastUser);
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
state.pending = 0;
state.lastUser = null;
} }
setStatus('idle'); setStatus('idle');
}, },
/** 清空本地打卡缓存与脏数据(用于切换账号时的安全重置) */
clearLocalCache() {
localStorage.removeItem(LS.records);
localStorage.removeItem(LS.adjust);
localStorage.removeItem(LS.dirty);
localStorage.removeItem(LS.rev);
localStorage.removeItem(LS.hold);
dirty.records.clear(); dirty.adjust.clear(); dirty.settings = false;
state.pending = 0;
state.rev = 0;
state.hold = null;
emit();
},
getLastUser() {
return readLS(LS.lastUser, null) || state.lastUser;
},
/** 用服务器数据整体覆盖本地(换设备后的首次拉取) */ /** 用服务器数据整体覆盖本地(换设备后的首次拉取) */
async pullAll() { async pullAll() {
const dump = await api('/api/data'); const dump = await api('/api/data');