feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示
- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询 - 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册 - 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示 - 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」 - 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
This commit is contained in:
@@ -57,8 +57,8 @@
|
||||
额度用尽后转纯后台轮询。技术日志仅输出到控制台(`[boot-guard]` 前缀)。
|
||||
- `shared/payroll.js`:独立同构薪酬计算模块(包含时薪折算、截断规则、周期合并等 30+ 纯函数)。
|
||||
- `sync.js`:本地优先增量同步适配层。
|
||||
- `server/`:轻量级 REST API 服务端(支持鉴权、版本同步、健康检查 `/api/health`),
|
||||
并内置只读 MCP 端点(`POST /mcp`):AI 客户端可查询出勤与薪酬,配置在页面「设置 → AI 接入 (MCP)」管理,开关/令牌即时生效。
|
||||
- `server/`:轻量级 REST API 服务端(支持多用户鉴权、版本同步、健康检查 `/api/health`),
|
||||
并内置只读 MCP 端点(`POST /mcp`):AI 客户端可查询出勤与薪酬。支持多用户独立 MCP 令牌隔离,配置在页面「设置 → AI 接入 (MCP)」管理,开关/令牌即时生效。
|
||||
- `.build/`:Tailwind CSS 预编译工作区(只在开发机跑,产物是 `vendor/tailwind.css`,运行时不需要它)。
|
||||
**改 `index.html` 里出现的新 Tailwind class 后必须重跑**:`cd .build && npx tailwindcss -i input.css -o ../vendor/tailwind.css --minify`,
|
||||
否则新样式不在预编译产物里,页面静默丢样式(2026-09-13 MCP 开关按钮不可见即此因)。
|
||||
@@ -103,7 +103,7 @@ systemctl enable --now work-hours-tracker.service
|
||||
```
|
||||
|
||||
单元文件关键内容(端口固定 9522:localStorage 按 origin 隔离,换端口 = 换 origin,
|
||||
已有打卡数据会看不见,勿改):
|
||||
已有打卡数据会看不见,勿改;如需开启多用户注册,可添加 `Environment=ALLOW_REGISTER=1`):
|
||||
```ini
|
||||
[Unit]
|
||||
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
|
||||
Environment=PORT=9522
|
||||
Environment=HOST=0.0.0.0
|
||||
# Environment=ALLOW_REGISTER=1 # 开放多用户自适应注册(首个用户创建后如需继续允许新账号注册可启用)
|
||||
Restart=always
|
||||
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` /
|
||||
`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),不设也行
|
||||
- 状态语义:未配置 → `404`(端点视为不存在);已配置但关闭 → `503`;令牌错误 → `401`
|
||||
- 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、
|
||||
`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
@@ -429,7 +429,6 @@
|
||||
<span class="font-semibold text-slate-600">合计应得</span>
|
||||
<span class="text-lg font-bold text-indigo-600 font-mono-num">{{ money(earnedTotal) }}</span>
|
||||
</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>
|
||||
<!-- 两套口径之间唯一的桥:明说挣得的钱里哪一部分已发、哪一部分要结转。
|
||||
没有这一行,用户看到挣得 ≠ 进账就会懿。 -->
|
||||
@@ -645,6 +644,10 @@
|
||||
<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) }} 小时。
|
||||
</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>
|
||||
</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" />
|
||||
<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 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>
|
||||
<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'">
|
||||
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
|
||||
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template>
|
||||
<template v-else-if="sync.allowRegister">服务端已开启多用户注册,填写用户名密码即可直接创建账号。</template>
|
||||
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
|
||||
</p>
|
||||
<label class="flex items-center justify-between gap-2 px-5 py-3">
|
||||
@@ -1374,6 +1378,12 @@ createApp({
|
||||
// 处罚吃穿下半月工时时,结转那笔被 ¥0 兰住,于是 paid+carry > earnedTotal。页面需要明说。
|
||||
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 recordCount = computed(() => Object.keys(records.value).length);
|
||||
@@ -1720,7 +1730,7 @@ createApp({
|
||||
// ===== 云端同步(离线优先:本地 localStorage 仍是唯一真相,同步只是额外把数据推到服务器)=====
|
||||
const sync = reactive({
|
||||
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 loginForm = ref({ username: '', password: '' });
|
||||
@@ -2072,6 +2082,7 @@ createApp({
|
||||
sync.lastSyncAt = s.lastSyncAt;
|
||||
sync.lastError = s.lastError;
|
||||
sync.serverUsers = s.serverUsers;
|
||||
sync.allowRegister = s.allowRegister;
|
||||
sync.hold = s.hold;
|
||||
});
|
||||
sync.apiBase = window.WHTSync.getApiBase() === location.origin ? '' : window.WHTSync.getApiBase();
|
||||
@@ -2106,7 +2117,7 @@ createApp({
|
||||
copied, dataCopied, toast,
|
||||
monthStats, prevStats, prevLabel, nextLabel,
|
||||
payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
|
||||
earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed,
|
||||
earnedTotal, cashTotal, earnedSplit, earnedFloored, payDayAPassed, effectiveHourly,
|
||||
fullDaysLeft,
|
||||
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
|
||||
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
|
||||
|
||||
@@ -156,4 +156,62 @@ export class D1Store {
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
`).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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,4 +188,62 @@ export class SqliteStore {
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
`).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
@@ -6,7 +6,7 @@ import {
|
||||
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
|
||||
} from './auth.js';
|
||||
import {
|
||||
readMcpConfig, saveMcpToken, setMcpEnabled, newMcpToken, maskToken
|
||||
readUserMcpConfig, saveUserMcpToken, setUserMcpEnabled, newMcpToken, maskToken
|
||||
} from './mcp.js';
|
||||
import {
|
||||
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
|
||||
@@ -102,7 +102,13 @@ export async function handleApi(request, store, opts = {}) {
|
||||
try {
|
||||
// ---------- 健康检查 ----------
|
||||
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 接入管理(登录态即可操作;单人系统无特权分级) ----------
|
||||
// 开关与令牌存 DB,改动即时生效;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
|
||||
// 开关与令牌存 user_mcp_tokens 表,每个用户独立隔离;令牌明文只在 POST /api/mcp/token 的响应里出现一次。
|
||||
if (path.startsWith('/api/mcp/')) {
|
||||
await requireAuth(store, request);
|
||||
const user = await requireAuth(store, request);
|
||||
const sub = path.slice('/api/mcp/'.length);
|
||||
|
||||
if (sub === 'status' && method === 'GET') {
|
||||
const cfg = await readMcpConfig(store, opts.mcpSeedToken);
|
||||
const cfg = await readUserMcpConfig(store, user.id, opts.mcpSeedToken);
|
||||
return json({
|
||||
ok: true, configured: cfg.configured, enabled: cfg.enabled,
|
||||
masked: cfg.configured ? maskToken(cfg.token) : null
|
||||
@@ -252,18 +258,18 @@ export async function handleApi(request, store, opts = {}) {
|
||||
}
|
||||
if (sub === 'token' && method === 'POST') {
|
||||
const token = newMcpToken();
|
||||
await saveMcpToken(store, token);
|
||||
await saveUserMcpToken(store, user.id, token);
|
||||
return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' });
|
||||
}
|
||||
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);
|
||||
return json({ ok: true, token: cfg.token });
|
||||
}
|
||||
if (sub === 'enabled' && method === 'POST') {
|
||||
const body = await readJson(request);
|
||||
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 err('接口不存在: ' + path, 404);
|
||||
|
||||
+71
-32
@@ -27,6 +27,7 @@
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import payroll from '../../shared/payroll.js';
|
||||
import { hashToken } from './auth.js';
|
||||
|
||||
const SERVER_INFO = { name: 'work-hours-tracker-mcp', version: '1.0.0' };
|
||||
// 与 MCP 官方已发布版本对齐;客户端发来的版本若认得就原样回,否则回兜底版本。
|
||||
@@ -170,32 +171,47 @@ export function newMcpToken() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读生效配置:{ configured, enabled, token }。
|
||||
* 状态语义刻意分开:未配置 → 端点 404(视为不存在);已配置但关闭 → 503
|
||||
* (「配了但被关了,可从后台重新打开」);令牌错 → 401。
|
||||
* @param {object} store
|
||||
* @param {string} seedToken env 里的首启令牌;入口层传入,core 不直接读 process.env(保持平台无关)
|
||||
* 读指定用户的生效配置:{ configured, enabled, token }。
|
||||
* 兼容旧数据与种子:如果 user_mcp_tokens 查无记录,但 app_settings/seedToken 中存在旧 token,
|
||||
* 则自动为 id=1 的老用户迁入,保证向后无感兼容。
|
||||
*/
|
||||
export async function readMcpConfig(store, seedToken = '') {
|
||||
let token = await store.getSetting(MCP_KEYS.token);
|
||||
if (!token && seedToken) {
|
||||
// 首次种子:env 有令牌且库里从没有过 → 迁入 DB(兼容旧部署,现有 token 无感切换)
|
||||
await store.setSetting(MCP_KEYS.token, seedToken);
|
||||
token = seedToken;
|
||||
export async function readUserMcpConfig(store, userId, seedToken = '') {
|
||||
let cfg = await store.getUserMcpConfig(userId);
|
||||
if (cfg && cfg.configured) return cfg;
|
||||
|
||||
// 兼容老单人自用历史数据迁移
|
||||
if (userId === 1) {
|
||||
let legacyToken = await store.getSetting(MCP_KEYS.token);
|
||||
if (!legacyToken && seedToken) {
|
||||
legacyToken = seedToken;
|
||||
await store.setSetting(MCP_KEYS.token, legacyToken);
|
||||
}
|
||||
if (legacyToken) {
|
||||
const legacyEnabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!token) return { configured: false, enabled: false, token: null };
|
||||
const enabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
|
||||
return { configured: true, enabled, token };
|
||||
|
||||
return { configured: false, enabled: false, token: null };
|
||||
}
|
||||
|
||||
/** 生成/轮换令牌并自动启用;明文只应出现在本次调用方的响应里,DB 之外不留副本 */
|
||||
export async function saveMcpToken(store, token) {
|
||||
await store.setSetting(MCP_KEYS.token, token);
|
||||
await store.setSetting(MCP_KEYS.enabled, '1');
|
||||
/** 生成/轮换用户专有令牌并自动启用 */
|
||||
export async function saveUserMcpToken(store, userId, token) {
|
||||
const tokenHash = await hashToken(token);
|
||||
await store.saveUserMcpToken(userId, token, tokenHash);
|
||||
}
|
||||
|
||||
export async function setMcpEnabled(store, enabled) {
|
||||
await store.setSetting(MCP_KEYS.enabled, enabled ? '1' : '0');
|
||||
export async function setUserMcpEnabled(store, userId, enabled) {
|
||||
await store.setUserMcpEnabled(userId, enabled);
|
||||
}
|
||||
|
||||
// ---------- 参数校验 ----------
|
||||
@@ -533,28 +549,52 @@ async function handleRpcMessage(msg, store, userId) {
|
||||
|
||||
/**
|
||||
* HTTP 入口。
|
||||
* 用户定位:MCP token 与站点登录态无关;当前是单人自用系统,固定取 id=1 的首个用户
|
||||
* (与 /api/health 的 users:1 对齐)。多人化时这里要换成 token→user 映射。
|
||||
* 多用户架构:MCP 请求由 Bearer Token 鉴权,根据 tokenHash 检索绑定的 user。
|
||||
* 完全解绑单人 id=1 硬编码,不同用户的 AI 客户端独立查各自打卡与薪酬。
|
||||
* @param {Request} request
|
||||
* @param {object} store SqliteStore / D1Store 实例
|
||||
* @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用)
|
||||
*/
|
||||
export async function handleMcp(request, store, opts = {}) {
|
||||
const cfg = await readMcpConfig(store, opts.seedToken);
|
||||
if (!cfg.configured) {
|
||||
return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 });
|
||||
}
|
||||
if (!cfg.enabled) {
|
||||
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
|
||||
// 全局若未配置过任何可用 token,端点按设计直接关闭(404 隐藏端点)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method Not Allowed(本端点只接受 POST)', {
|
||||
status: 405, headers: { allow: 'POST' }
|
||||
});
|
||||
}
|
||||
|
||||
const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');
|
||||
if (!provided || !tokenEquals(provided, cfg.token)) {
|
||||
return json({ error: 'unauthorized' }, 401);
|
||||
if (!provided) {
|
||||
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;
|
||||
@@ -564,8 +604,7 @@ export async function handleMcp(request, store, opts = {}) {
|
||||
return json(rpcError(null, -32700, 'JSON 解析失败'), 400);
|
||||
}
|
||||
|
||||
const user = await store.getUserById(1);
|
||||
if (!user) return json(rpcError(null, -32603, '系统中还没有用户'), 500);
|
||||
const user = hit.user;
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
const responses = [];
|
||||
|
||||
+11
-10
@@ -66,20 +66,21 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。
|
||||
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议,
|
||||
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。
|
||||
-- App 级键值:服务自身的运行时配置(保留用于全局系统配置)
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。
|
||||
-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议,
|
||||
-- 客户端 push 碰不到它,增量拉取也不会把它带出去。
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
-- 用户级 MCP Token 表:支持多用户独立配置 AI 接入令牌
|
||||
CREATE TABLE IF NOT EXISTS user_mcp_tokens (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
token TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_mcp_hash ON user_mcp_tokens(token_hash);
|
||||
|
||||
|
||||
@@ -25,10 +25,17 @@
|
||||
// // 全量导出,用于备份与首次同步
|
||||
// fullDump(userId): Promise<{ rev, records, adjust, settings }>
|
||||
//
|
||||
// // ---- App 级键值 ----(运行时配置,如 MCP 开关/令牌;不属于任何用户,不参与同步)
|
||||
// // ---- App 级键值 ----(运行时配置,如全局系统开关;不属于任何用户,不参与同步)
|
||||
// getSetting(key): Promise<string|null>
|
||||
// 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 }
|
||||
|
||||
export const HOUR_MIN = 6;
|
||||
|
||||
+47
-2
@@ -39,12 +39,12 @@ async function rpc(msg, token) {
|
||||
|
||||
// 管理 API:与 api.test.js 同一套调用模式
|
||||
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' };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await handleApi(new Request('http://t' + path, {
|
||||
method, headers, body: body === undefined ? undefined : JSON.stringify(body)
|
||||
}), store, {});
|
||||
}), store, opts);
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch {}
|
||||
return { status: res.status, body: json };
|
||||
@@ -168,6 +168,51 @@ section('Token 轮换:旧的立即失效');
|
||||
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();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
settings: 'work_settings',
|
||||
token: 'work_sync_token',
|
||||
user: 'work_sync_user',
|
||||
lastUser: 'work_sync_last_user',
|
||||
rev: 'work_sync_rev',
|
||||
dirty: 'work_sync_dirty',
|
||||
base: 'work_sync_base',
|
||||
@@ -68,7 +69,9 @@
|
||||
// 必须落 localStorage:否则用户选了「先不处理」后一刷新,阀就没了,
|
||||
// 下一次 syncNow 会把远端值静默盖到本机冲突日上。
|
||||
hold: readLS(LS.hold, null),
|
||||
serverUsers: null // health 探测到的服务端用户数,用于判断该显示「注册」还是「登录」
|
||||
serverUsers: null, // health 探测到的服务端用户数
|
||||
allowRegister: null, // 是否开放注册
|
||||
lastUser: readLS(LS.lastUser, null)
|
||||
};
|
||||
const dirty = loadDirty();
|
||||
state.pending = dirty.records.size + dirty.adjust.size + (dirty.settings ? 1 : 0);
|
||||
@@ -404,10 +407,12 @@
|
||||
try {
|
||||
const h = await api('/api/health', { auth: false, timeoutMs: 6000 });
|
||||
state.serverUsers = h.users;
|
||||
state.allowRegister = !!h.allowRegister;
|
||||
emit();
|
||||
return h;
|
||||
} catch (e) {
|
||||
state.serverUsers = null;
|
||||
state.allowRegister = null;
|
||||
emit();
|
||||
throw e;
|
||||
}
|
||||
@@ -422,8 +427,10 @@
|
||||
const r = await api('/api/login', { method: 'POST', auth: false, body: { username, password } });
|
||||
state.token = r.token;
|
||||
state.user = r.user;
|
||||
state.lastUser = r.user;
|
||||
writeLS(LS.token, r.token);
|
||||
writeLS(LS.user, r.user);
|
||||
writeLS(LS.lastUser, r.user);
|
||||
// 换账号/首次登录:从 0 开始拉全量,避免沿用上一个账号的 rev 导致漏数据
|
||||
state.rev = 0;
|
||||
writeLS(LS.rev, 0);
|
||||
@@ -441,10 +448,33 @@
|
||||
if (!keepLocal) {
|
||||
localStorage.removeItem(LS.records);
|
||||
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');
|
||||
},
|
||||
|
||||
/** 清空本地打卡缓存与脏数据(用于切换账号时的安全重置) */
|
||||
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() {
|
||||
const dump = await api('/api/data');
|
||||
|
||||
Reference in New Issue
Block a user